diff --git a/projects/core/src/internal/controllers/state-active.controller.test.ts b/projects/core/src/internal/controllers/state-active.controller.test.ts deleted file mode 100644 index 97dbae3ca..000000000 --- a/projects/core/src/internal/controllers/state-active.controller.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { html, LitElement } from 'lit'; -import { customElement } from 'lit/decorators/custom-element.js'; -import { property } from 'lit/decorators/property.js'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { stateActive } from '@nvidia-elements/core/internal'; -import { createFixture, removeFixture, elementIsStable } from '@internals/testing'; - -@stateActive() -@customElement('state-active-controller-test-element') -class StateActiveControllerTestElement extends LitElement { - @property({ type: Boolean }) disabled = false; -} - -/** - * https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/states - */ -describe('state-active.controller', () => { - let element: StateActiveControllerTestElement; - let fixture: HTMLElement; - - beforeEach(async () => { - fixture = await createFixture(html``); - element = fixture.querySelector('state-active-controller-test-element'); - }); - - afterEach(() => { - removeFixture(fixture); - }); - - it('should add active state on mousedown', async () => { - expect(element.matches(':state(active)')).toBe(false); - - element.dispatchEvent(new MouseEvent('mousedown')); - expect(element.matches(':state(active)')).toBe(true); - - element.dispatchEvent(new MouseEvent('mouseup')); - expect(element.matches(':state(active)')).toBe(false); - }); - - it('should not add active state if element is disabled', async () => { - element.disabled = true; - expect(element.matches(':state(active)')).toBe(false); - - element.dispatchEvent(new MouseEvent('mousedown')); - expect(element.matches(':state(active)')).toBe(false); - }); - - it('should not trigger scroll behavior when Space is pressed', async () => { - const original = document.body.getBoundingClientRect().top; - - element.dispatchEvent(new KeyboardEvent('keypress', { code: 'Space' })); - await elementIsStable(element); - - expect(document.body.getBoundingClientRect().top).toBe(original); - }); - - it('should add active state on space keypress', async () => { - expect(element.matches(':state(active)')).toBe(false); - - element.dispatchEvent(new KeyboardEvent('keypress', { code: 'Space' })); - expect(element.matches(':state(active)')).toBe(true); - - element.dispatchEvent(new KeyboardEvent('keyup')); - expect(element.matches(':state(active)')).toBe(false); - }); - - it('should add active state on enter keypress', async () => { - expect(element.matches(':state(active)')).toBe(false); - - element.dispatchEvent(new KeyboardEvent('keypress', { code: 'Enter' })); - expect(element.matches(':state(active)')).toBe(true); - - element.dispatchEvent(new KeyboardEvent('keyup')); - expect(element.matches(':state(active)')).toBe(false); - }); - - it('should not add active state on any invalid keypress', async () => { - expect(element.matches(':state(active)')).toBe(false); - - element.dispatchEvent(new KeyboardEvent('keypress', { code: 'KeyK' })); - expect(element.matches(':state(active)')).toBe(false); - - element.dispatchEvent(new KeyboardEvent('keyup')); - expect(element.matches(':state(active)')).toBe(false); - }); - - it('should remove active state on blur', async () => { - element.dispatchEvent(new MouseEvent('mousedown')); - expect(element.matches(':state(active)')).toBe(true); - - element.dispatchEvent(new Event('blur')); - expect(element.matches(':state(active)')).toBe(false); - }); - - it('should not add active state on mousedown when disabled', async () => { - element.disabled = true; - await elementIsStable(element); - - element.dispatchEvent(new MouseEvent('mousedown')); - expect(element.matches(':state(active)')).toBe(false); - }); - - it('should not add active state on keypress when disabled', async () => { - element.disabled = true; - await elementIsStable(element); - - element.dispatchEvent(new KeyboardEvent('keypress', { code: 'Enter' })); - expect(element.matches(':state(active)')).toBe(false); - }); -}); diff --git a/projects/core/src/internal/controllers/state-active.controller.ts b/projects/core/src/internal/controllers/state-active.controller.ts deleted file mode 100644 index 86857e103..000000000 --- a/projects/core/src/internal/controllers/state-active.controller.ts +++ /dev/null @@ -1,59 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import type { ReactiveController, ReactiveElement } from 'lit'; -import type { LegacyDecoratorTarget } from '../types/index.js'; -import { attachInternals } from '../utils/a11y.js'; - -/** - * Adds CSS State psuedo-selector :state(active) behavior for keydown space/enter for custom elements - * https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/states - */ -export function stateActive(): ClassDecorator { - return (target: LegacyDecoratorTarget) => - target.addInitializer!((instance: T) => new StateActiveController(instance)); -} - -type Active = ReactiveElement & { disabled: boolean; _internals?: ElementInternals }; - -export class StateActiveController implements ReactiveController { - #initialized = false; - constructor(private host: T) { - this.host.addController(this); - } - - hostConnected() { - attachInternals(this.host); - this.host.addEventListener('keypress', this.#emulateActive as EventListener); - this.host.addEventListener('mousedown', this.#emulateActive as EventListener); - this.host.addEventListener('keyup', this.#emulateInactive); - this.host.addEventListener('blur', this.#emulateInactive); - this.host.addEventListener('mouseup', this.#emulateInactive); - } - - hostDisconnected() { - this.host.removeEventListener('keypress', this.#emulateActive as EventListener); - this.host.removeEventListener('mousedown', this.#emulateActive as EventListener); - this.host.removeEventListener('keyup', this.#emulateInactive); - this.host.removeEventListener('blur', this.#emulateInactive); - this.host.removeEventListener('mouseup', this.#emulateInactive); - } - - #emulateActive = (e: KeyboardEvent | PointerEvent) => { - if (!this.host.disabled && this.#isValidKeyEvent(e)) { - this.host._internals!.states.add('active'); - } - - if (e instanceof KeyboardEvent && e.code === 'Space' && e.target === this.host) { - e.preventDefault(); // prevent space bar scroll with standard button behavior - } - }; - - #emulateInactive = () => { - this.host._internals!.states.delete('active'); - }; - - #isValidKeyEvent(e: KeyboardEvent | PointerEvent) { - return e instanceof KeyboardEvent ? e.code === 'Space' || e.code === 'Enter' : true; - } -} diff --git a/projects/core/src/internal/controllers/state-current.controller.test.ts b/projects/core/src/internal/controllers/state-current.controller.test.ts deleted file mode 100644 index 4ab22f8d0..000000000 --- a/projects/core/src/internal/controllers/state-current.controller.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { html, LitElement } from 'lit'; -import { customElement } from 'lit/decorators/custom-element.js'; -import { property } from 'lit/decorators/property.js'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { createFixture, removeFixture, elementIsStable } from '@internals/testing'; -import { stateCurrent } from '@nvidia-elements/core/internal'; - -@stateCurrent() -@customElement('state-current-controller-test-element') -class StateCurrentControllerTestElement extends LitElement { - @property({ type: String }) current: 'page' | 'step'; - @property({ type: Boolean, attribute: 'readonly' }) readOnly = false; - declare _internals: ElementInternals; - - render() { - return html``; - } -} - -/** - * https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/states - */ -describe('state-current.controller', () => { - let element: StateCurrentControllerTestElement; - let fixture: HTMLElement; - - beforeEach(async () => { - fixture = await createFixture( - html`` - ); - element = fixture.querySelector('state-current-controller-test-element'); - }); - - afterEach(() => { - removeFixture(fixture); - }); - - it('should initialize aria-current as null', async () => { - await elementIsStable(element); - expect(element._internals.ariaCurrent).toBe(null); - expect(element.matches(':state(current)')).toBe(false); - }); - - it('should initialize aria-current as null if current not applied', async () => { - await elementIsStable(element); - expect(element._internals.ariaCurrent).toBe(null); - expect(element.matches(':state(current)')).toBe(false); - }); - - it('should initialize aria-current if current applied', async () => { - element.current = 'page'; - await elementIsStable(element); - expect(element._internals.ariaCurrent).toBe('page'); - expect(element.matches(':state(current)')).toBe(true); - }); - - it('should initialize aria-current as step if current=step is applied', async () => { - element.current = 'step'; - await elementIsStable(element); - expect(element._internals.ariaCurrent).toBe('step'); - expect(element.matches(':state(current)')).toBe(true); - }); - - it('should remove aria-current if readonly', async () => { - element.current = 'page'; - await elementIsStable(element); - expect(element._internals.ariaCurrent).toBe('page'); - expect(element.matches(':state(current)')).toBe(true); - - element.readOnly = true; - await elementIsStable(element); - expect(element._internals.ariaCurrent).toBe(null); - expect(element.matches(':state(current)')).toBe(false); - }); - - it('should apply aria-current="page" if a current anchor', async () => { - const a = document.createElement('a'); - a.href = '#'; - element.appendChild(a); - element.current = 'page'; - element._internals.states.add('anchor'); // typically added via type-anchor controller in base button - element.requestUpdate(); - await elementIsStable(element); - - expect(a.getAttribute('aria-current')).toBe('page'); - }); - - it('should not set aria-current on host internals when anchor state is active', async () => { - const a = document.createElement('a'); - a.href = '#'; - element.appendChild(a); - element.current = 'page'; - element._internals.states.add('anchor'); - element.requestUpdate(); - await elementIsStable(element); - - expect(element._internals.ariaCurrent).toBe(null); - expect(element.matches(':state(current)')).toBe(true); - expect(a.getAttribute('aria-current')).toBe('page'); - }); - - it('should prioritize readonly over anchor state', async () => { - const a = document.createElement('a'); - a.href = '#'; - element.appendChild(a); - element.current = 'page'; - element.readOnly = true; - element._internals.states.add('anchor'); - element.requestUpdate(); - await elementIsStable(element); - - expect(element._internals.ariaCurrent).toBe(null); - expect(element.matches(':state(current)')).toBe(false); - expect(a.getAttribute('aria-current')).toBe(null); - }); - - it('should restore current state when readonly is removed', async () => { - element.current = 'page'; - element.readOnly = true; - await elementIsStable(element); - expect(element._internals.ariaCurrent).toBe(null); - expect(element.matches(':state(current)')).toBe(false); - - element.readOnly = false; - await elementIsStable(element); - expect(element._internals.ariaCurrent).toBe('page'); - expect(element.matches(':state(current)')).toBe(true); - }); -}); diff --git a/projects/core/src/internal/controllers/state-current.controller.ts b/projects/core/src/internal/controllers/state-current.controller.ts deleted file mode 100644 index 9bc59069b..000000000 --- a/projects/core/src/internal/controllers/state-current.controller.ts +++ /dev/null @@ -1,52 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import type { ReactiveController, ReactiveElement } from 'lit'; -import type { LegacyDecoratorTarget } from '../types/index.js'; -import { attachInternals } from '../utils/a11y.js'; - -/** - * Adds current support for interactive custom elements including CSS State psuedo-selector :state(current) and aria-current. - * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-current - */ -export function stateCurrent(): ClassDecorator { - return (target: LegacyDecoratorTarget) => - target.addInitializer!((instance: T) => new StateCurrentController(instance)); -} - -type Current = ReactiveElement & { current: 'page' | 'step'; readOnly?: boolean; _internals?: ElementInternals }; - -export class StateCurrentController implements ReactiveController { - constructor(private host: T) { - this.host.addController(this); - } - - hostConnected() { - attachInternals(this.host); - } - - hostUpdated() { - if (this.host.readOnly) { - this.host._internals!.ariaCurrent = null; - this.host._internals!.states.delete('current'); - return; - } - - if (this.host._internals?.states.has('anchor') && this.host.current) { - this.host._internals!.ariaCurrent = null; - this.host._internals!.states.add('current'); - this.host.querySelector('a')?.setAttribute('aria-current', 'page'); - return; - } - - if (this.host.current !== null && this.host.current !== undefined) { - this.host._internals!.ariaCurrent = `${this.host.current}`; - } - - if (this.host.current) { - this.host._internals!.states.add('current'); - } else { - this.host._internals!.states.delete('current'); - } - } -} diff --git a/projects/core/src/internal/controllers/state-disabled.controller.test.ts b/projects/core/src/internal/controllers/state-disabled.controller.test.ts deleted file mode 100644 index 351c1b600..000000000 --- a/projects/core/src/internal/controllers/state-disabled.controller.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { html, LitElement } from 'lit'; -import { customElement } from 'lit/decorators/custom-element.js'; -import { property } from 'lit/decorators/property.js'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { createFixture, removeFixture, elementIsStable } from '@internals/testing'; -import { stateDisabled } from '@nvidia-elements/core/internal'; - -@stateDisabled() -@customElement('state-disabled-controller-test-element') -class StateDisabledControllerTestElement extends LitElement { - @property({ type: Boolean }) disabled = false; - @property({ type: Boolean, attribute: 'readonly' }) readOnly = false; - declare _internals: ElementInternals; -} - -/** - * https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/states - */ -describe('state-disabled.controller', () => { - let element: StateDisabledControllerTestElement; - let fixture: HTMLElement; - - beforeEach(async () => { - fixture = await createFixture( - html`` - ); - element = fixture.querySelector('state-disabled-controller-test-element'); - }); - - afterEach(() => { - removeFixture(fixture); - }); - - it('should initialize aria-disabled', async () => { - element.disabled = true; - await elementIsStable(element); - expect(element._internals.ariaDisabled).toBe('true'); - expect(element.matches(':state(disabled)')).toBe(true); - }); - - it('should update aria-disabled when disabled API is updated', async () => { - element.disabled = true; - await elementIsStable(element); - expect(element._internals.ariaDisabled).toBe('true'); - expect(element.matches(':state(disabled)')).toBe(true); - - element.disabled = false; - await elementIsStable(element); - expect(element._internals.ariaDisabled).toBe('false'); - expect(element.matches(':state(disabled)')).toBe(false); - }); - - it('should remove aria-disabled if readonly', async () => { - element.readOnly = true; - await elementIsStable(element); - expect(element._internals.ariaDisabled).toBe(null); - expect(element.matches(':state(disabled)')).toBe(false); - }); - - it('should remove aria-disabled set to null (element can no longer be disabled)', async () => { - element.disabled = null; - await elementIsStable(element); - expect(element._internals.ariaDisabled).toBe(null); - expect(element.matches(':state(disabled)')).toBe(false); - }); -}); diff --git a/projects/core/src/internal/controllers/state-disabled.controller.ts b/projects/core/src/internal/controllers/state-disabled.controller.ts deleted file mode 100644 index 15663bbb6..000000000 --- a/projects/core/src/internal/controllers/state-disabled.controller.ts +++ /dev/null @@ -1,46 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import type { ReactiveController, ReactiveElement } from 'lit'; -import type { LegacyDecoratorTarget } from '../types/index.js'; -import { attachInternals } from '../utils/a11y.js'; - -/** - * Adds disabled support for interactive custom elements including CSS State psuedo-selector :state(disabled) and aria-disabled. - * https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/states - * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-disabled - */ -export function stateDisabled(): ClassDecorator { - return (target: LegacyDecoratorTarget) => - target.addInitializer!((instance: T) => new StateDisabledController(instance)); -} - -export type Disabled = ReactiveElement & { disabled: boolean; readOnly?: boolean; _internals?: ElementInternals }; - -export class StateDisabledController implements ReactiveController { - constructor(private host: T) { - this.host.addController(this); - } - - hostConnected() { - attachInternals(this.host); - } - - hostUpdated() { - if (this.host.disabled !== null && this.host.disabled !== undefined) { - this.host._internals!.ariaDisabled = `${this.host.disabled}`; - } else { - this.host._internals!.ariaDisabled = null; - } - - if (this.host.disabled) { - this.host._internals!.states.add('disabled'); - } else { - this.host._internals!.states.delete('disabled'); - } - - if (this.host.readOnly) { - this.host._internals!.ariaDisabled = null; - } - } -} diff --git a/projects/core/src/internal/controllers/state-pressed.controller.test.ts b/projects/core/src/internal/controllers/state-pressed.controller.test.ts deleted file mode 100644 index 908ddfb2b..000000000 --- a/projects/core/src/internal/controllers/state-pressed.controller.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { html, LitElement } from 'lit'; -import { customElement } from 'lit/decorators/custom-element.js'; -import { property } from 'lit/decorators/property.js'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { createFixture, removeFixture, elementIsStable } from '@internals/testing'; -import { statePressed } from '@nvidia-elements/core/internal'; - -@statePressed() -@customElement('state-pressed-controller-test-element') -class StatePressedControllerTestElement extends LitElement { - @property({ type: Boolean }) pressed: boolean; - @property({ type: Boolean, attribute: 'readonly' }) readOnly = false; - declare _internals: ElementInternals; -} - -/** - * https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/states - */ -describe('state-pressed.controller', () => { - let element: StatePressedControllerTestElement; - let fixture: HTMLElement; - - beforeEach(async () => { - fixture = await createFixture( - html`` - ); - element = fixture.querySelector('state-pressed-controller-test-element'); - }); - - afterEach(() => { - removeFixture(fixture); - }); - - it('should initialize aria-pressed as null', async () => { - await elementIsStable(element); - expect(element._internals.ariaPressed).toBe(null); - expect(element.matches(':state(pressed)')).toBe(false); - }); - - it('should initialize aria-pressed as null if pressed not applied', async () => { - element.pressed = true; - await elementIsStable(element); - expect(element._internals.ariaPressed).toBe('true'); - expect(element.matches(':state(pressed)')).toBe(true); - }); - - it('should initialize aria-pressed as false if pressed=false applied', async () => { - element.pressed = false; - await elementIsStable(element); - expect(element._internals.ariaPressed).toBe('false'); - expect(element.matches(':state(pressed)')).toBe(false); - }); - - it('should remove aria-pressed if readonly', async () => { - element.pressed = true; - await elementIsStable(element); - expect(element._internals.ariaPressed).toBe('true'); - expect(element.matches(':state(pressed)')).toBe(true); - - element.readOnly = true; - await elementIsStable(element); - expect(element._internals.ariaPressed).toBe(null); - expect(element.matches(':state(pressed)')).toBe(false); - }); -}); diff --git a/projects/core/src/internal/controllers/state-pressed.controller.ts b/projects/core/src/internal/controllers/state-pressed.controller.ts deleted file mode 100644 index d6d5efd81..000000000 --- a/projects/core/src/internal/controllers/state-pressed.controller.ts +++ /dev/null @@ -1,45 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import type { ReactiveController, ReactiveElement } from 'lit'; -import type { LegacyDecoratorTarget } from '../types/index.js'; -import { attachInternals } from '../utils/a11y.js'; - -/** - * Adds pressed support for interactive custom elements including CSS State psuedo-selector :state(pressed) and aria-pressed. - * https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/states - * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-pressed - */ -export function statePressed(): ClassDecorator { - return (target: LegacyDecoratorTarget) => - target.addInitializer!((instance: T) => new StatePressedController(instance)); -} - -export type Pressed = ReactiveElement & { pressed: boolean; readOnly?: boolean; _internals?: ElementInternals }; - -export class StatePressedController implements ReactiveController { - constructor(private host: T) { - this.host.addController(this); - } - - hostConnected() { - attachInternals(this.host); - } - - hostUpdated() { - if (this.host.pressed !== null && this.host.pressed !== undefined) { - this.host._internals!.ariaPressed = `${this.host.pressed}`; - } - - if (this.host.pressed) { - this.host._internals!.states.add('pressed'); - } else { - this.host._internals!.states.delete('pressed'); - } - - if (this.host.readOnly) { - this.host._internals!.ariaPressed = null; - this.host._internals!.states.delete('pressed'); - } - } -} diff --git a/projects/core/src/internal/controllers/type-button.controller.examples.ts b/projects/core/src/internal/controllers/type-button.controller.examples.ts deleted file mode 100644 index 9499f5d37..000000000 --- a/projects/core/src/internal/controllers/type-button.controller.examples.ts +++ /dev/null @@ -1,109 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { css, html, LitElement } from 'lit'; -import { ButtonFormControlMixin } from '@nvidia-elements/forms/mixins'; -import '@nvidia-elements/core/card/define.js'; -import '@nvidia-elements/core/button/define.js'; -import '@nvidia-elements/core/icon-button/define.js'; - -export default { - title: 'Internal/Controllers' -} - -class UIButton extends ButtonFormControlMixin(LitElement) { - static styles = [css` - :host { - --background: hsl(0, 0%, 90%); - --color: hsl(0, 0%, 0%); - --cursor: pointer; - display: inline-flex; - position: relative; - } - - [internal-host] { - background: var(--background); - color: var(--color); - cursor: var(--cursor); - padding: 8px 12px; - min-width: 75px; - text-align: center; - } - - /* element states */ - :host(:hover) { - --background: hsl(0, 0%, 95%); - } - - :host(:state(active)) { - --background: hsl(0, 0%, 85%); - } - - :host(:state(pressed)) { - --background: hsl(0, 0%, 85%); - } - - :host(:state(expanded)) { - --background: hsl(0, 0%, 85%); - } - - :host(:state(disabled)) { - --background: hsl(0, 0%, 80%); - --color: hsl(0, 0%, 60%); - --cursor: not-allowed; - } - - :host(:state(readonly)) { - --cursor: initial; - --color: blue; - } - - /* anchor styles */ - [internal-host]:focus-within { - outline: Highlight solid 2px; - outline: 5px auto -webkit-focus-ring-color; - } - - ::slotted(a) { - color: var(--color) !important; - text-decoration: none !important; - outline: 0 !important; - } - - ::slotted(a)::after { - position: absolute; - content: ''; - inset: 0; - display: block; - } - `] -} - -customElements.get('ui-button') || customElements.define('ui-button', UIButton); - -/** - * Example of custom element button using the button form control mixin. - * When a custom element applies the mixin it inherits button behaviors and states. - * @summary Custom button element using ButtonFormControlMixin with pressed, expanded, disabled, and link states. - * @tags test-case - */ -export const TypeButtonDemo = { - render: () => html` -button -pressed -expanded -selected -disabled -link - -
- submit -
-` -}; diff --git a/projects/core/src/internal/controllers/type-button.controller.test.ts b/projects/core/src/internal/controllers/type-button.controller.test.ts deleted file mode 100644 index 7c105cdfd..000000000 --- a/projects/core/src/internal/controllers/type-button.controller.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { html, LitElement } from 'lit'; -import { property } from 'lit/decorators/property.js'; -import { customElement } from 'lit/decorators/custom-element.js'; -import { describe, expect, it, beforeEach, afterEach } from 'vitest'; -import { typeButton } from '@nvidia-elements/core/internal'; -import { createFixture, removeFixture, elementIsStable } from '@internals/testing'; - -@typeButton() -@customElement('type-button-controller-test-element') -class TypeButtonControllerTestElement extends LitElement { - @property({ type: Boolean, attribute: 'readonly' }) readOnly = false; - @property({ type: Boolean }) disabled = false; - @property({ type: String }) href: string; - _internals: ElementInternals; -} - -describe('TypeButtonController', () => { - let element: TypeButtonControllerTestElement; - let fixture: HTMLElement; - - beforeEach(async () => { - fixture = await createFixture(html``); - element = fixture.querySelector('type-button-controller-test-element'); - }); - - afterEach(() => { - removeFixture(fixture); - }); - - it('should initialize tabindex 0 for focus behavior', async () => { - await elementIsStable(element); - expect(element.tabIndex).toBe(0); - }); - - it('should initialize role button', async () => { - await elementIsStable(element); - expect(element._internals.role).toBe('button'); - }); - - it('should remove tabindex if disabled', async () => { - element.disabled = true; - await elementIsStable(element); - expect(element.tabIndex).toBe(-1); - }); - - it('should remove tabindex and role if readonly', async () => { - element.readOnly = true; - await elementIsStable(element); - expect(element.tabIndex).toBe(-1); - expect(element._internals.role).toBe('none'); - expect(element.getAttribute('role')).toBe(null); - }); -}); diff --git a/projects/core/src/internal/controllers/type-button.controller.ts b/projects/core/src/internal/controllers/type-button.controller.ts deleted file mode 100644 index 7efadd10d..000000000 --- a/projects/core/src/internal/controllers/type-button.controller.ts +++ /dev/null @@ -1,52 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import type { ReactiveController, ReactiveElement } from 'lit'; -import type { LegacyDecoratorTarget } from '../types/index.js'; -import { attachInternals } from '../utils/a11y.js'; - -/** - * Adds button support for interactive custom elements including aria-button and focus behavior. - * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/button_role - */ -export function typeButton(): ClassDecorator { - return (target: LegacyDecoratorTarget) => target.addInitializer!((instance: T) => new TypeButtonController(instance)); -} - -export interface Button extends ReactiveElement { - readOnly: boolean; - disabled: boolean; - _internals?: ElementInternals; -} - -export class TypeButtonController implements ReactiveController { - #initialTabIndex: number; - - constructor(private host: T) { - this.host.addController(this); - } - - hostConnected() { - attachInternals(this.host); - - if (this.host.hasAttribute('tabindex')) { - this.#initialTabIndex = this.host.tabIndex; - } - } - - async hostUpdated() { - await this.host.updateComplete; - - if (!this.host._internals!.role) { - this.host._internals!.role = 'button'; - } - - this.host.tabIndex = this.host.disabled ? -1 : this.#initialTabIndex; - - if (this.host.readOnly) { - this.host._internals!.role = 'none'; - this.host.tabIndex = -1; - this.host.removeAttribute('tabindex'); - } - } -} diff --git a/projects/core/src/internal/controllers/type-command.controller.test.ts b/projects/core/src/internal/controllers/type-command.controller.test.ts deleted file mode 100644 index 61f0e20ae..000000000 --- a/projects/core/src/internal/controllers/type-command.controller.test.ts +++ /dev/null @@ -1,273 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { html, LitElement } from 'lit'; -import { customElement } from 'lit/decorators/custom-element.js'; -import { property } from 'lit/decorators/property.js'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createFixture, removeFixture, untilEvent, emulateClick, elementIsStable } from '@internals/testing'; -import { TypeCommandController } from '@nvidia-elements/core/internal'; - -class TypeCommandControllerTestElementBase extends LitElement { - @property({ type: String }) command: string; - @property({ type: String, attribute: 'commandfor' }) commandfor: string; - @property({ type: Object }) commandForElement: HTMLElement; - @property({ type: Boolean, attribute: 'readonly' }) readOnly = false; - @property({ type: Boolean }) disabled: boolean; -} - -@customElement('type-command-controller-test-element') -class TypeCommandControllerTestElement extends TypeCommandControllerTestElementBase { - _typeCommandController = new TypeCommandController(this); -} - -@customElement('manual-type-command-controller-test-element') -class ManualTypeCommandControllerTestElement extends TypeCommandControllerTestElementBase { - _typeCommandController = new TypeCommandController(this, { - trigger: 'manual' - }); -} - -describe('type-command.controller', () => { - let element: TypeCommandControllerTestElement; - let target: HTMLElement; - let fixture: HTMLElement; - - afterEach(() => { - removeFixture(fixture); - }); - - describe('commandfor attribute', () => { - beforeEach(async () => { - fixture = await createFixture( - html` - -
- ` - ); - element = fixture.querySelector('type-command-controller-test-element'); - target = fixture.querySelector('#target'); - }); - - it('should trigger command event when clicked', async () => { - const event = untilEvent(target, 'command'); - await emulateClick(element); - const { source, command } = await event; - expect(source).toBe(element); - expect(command).toBe('--test'); - }); - - it('should expose the resolved target', () => { - expect(element._typeCommandController.target).toBe(target); - }); - }); - - describe('commandForElement property', () => { - beforeEach(async () => { - fixture = await createFixture( - html` - -
- ` - ); - element = fixture.querySelector('type-command-controller-test-element'); - target = fixture.querySelector('#target'); - element.commandForElement = target; - await elementIsStable(element); - }); - - it('should trigger command event via element reference', async () => { - const event = untilEvent(target, 'command'); - await emulateClick(element); - const { source, command } = await event; - expect(source).toBe(element); - expect(command).toBe('--test'); - }); - - it('should expose the commandForElement target', () => { - expect(element._typeCommandController.target).toBe(target); - }); - - it('should prefer commandForElement over commandfor', async () => { - removeFixture(fixture); - fixture = await createFixture( - html` - -
-
- ` - ); - element = fixture.querySelector('type-command-controller-test-element'); - target = fixture.querySelector('#target-property'); - const targetById = fixture.querySelector('#target-id'); - element.commandForElement = target; - await elementIsStable(element); - - let commandForIdFired = false; - targetById.addEventListener('command', () => (commandForIdFired = true)); - const event = untilEvent(target, 'command'); - await emulateClick(element); - - expect((await event).command).toBe('--test'); - expect(commandForIdFired).toBe(false); - }); - }); - - describe('manual trigger', () => { - let manualElement: ManualTypeCommandControllerTestElement; - - beforeEach(async () => { - fixture = await createFixture( - html` - -
- ` - ); - manualElement = fixture.querySelector( - 'manual-type-command-controller-test-element' - ); - target = fixture.querySelector('#target'); - await elementIsStable(manualElement); - }); - - it('should not trigger command event when clicked', async () => { - let fired = false; - target.addEventListener('command', () => (fired = true)); - await emulateClick(manualElement); - - expect(fired).toBe(false); - }); - - it('should dispatch command event explicitly', async () => { - const event = untilEvent(target, 'command'); - const dispatched = manualElement._typeCommandController.dispatchCommand(); - - expect(dispatched).toBe(true); - const { source, command } = await event; - expect(source).toBe(manualElement); - expect(command).toBe('--test'); - }); - }); - - describe('readonly', () => { - beforeEach(async () => { - fixture = await createFixture( - html` - -
- ` - ); - element = fixture.querySelector('type-command-controller-test-element'); - target = fixture.querySelector('#target'); - await elementIsStable(element); - }); - - it('should not trigger command event when readonly', async () => { - let fired = false; - target.addEventListener('command', () => (fired = true)); - await emulateClick(element); - expect(fired).toBe(false); - }); - - it('should not dispatch command event when deprecated readonly property is true', async () => { - removeFixture(fixture); - fixture = await createFixture( - html` - -
- ` - ); - element = fixture.querySelector('type-command-controller-test-element'); - target = fixture.querySelector('#target'); - (element as TypeCommandControllerTestElement & { readOnly: boolean }).readOnly = true; - await elementIsStable(element); - - let fired = false; - target.addEventListener('command', () => (fired = true)); - - await emulateClick(element); - - expect(element._typeCommandController.dispatchCommand()).toBe(false); - expect(fired).toBe(false); - }); - }); - - describe('disabled', () => { - beforeEach(async () => { - fixture = await createFixture( - html` - -
- ` - ); - element = fixture.querySelector('type-command-controller-test-element'); - target = fixture.querySelector('#target'); - await elementIsStable(element); - }); - - it('should not trigger command event when disabled', async () => { - let fired = false; - target.addEventListener('command', () => (fired = true)); - await emulateClick(element); - expect(fired).toBe(false); - }); - }); - - describe('no target', () => { - beforeEach(async () => { - fixture = await createFixture( - html`` - ); - element = fixture.querySelector('type-command-controller-test-element'); - await elementIsStable(element); - }); - - it('should not throw when neither commandfor nor commandForElement is set', () => { - expect(() => emulateClick(element)).not.toThrow(); - }); - }); - - describe('missing target warning', () => { - beforeEach(async () => { - fixture = await createFixture( - html` - - ` - ); - element = fixture.querySelector('type-command-controller-test-element'); - await elementIsStable(element); - }); - - it('should warn when commandfor references a nonexistent element', async () => { - const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - await emulateClick(element); - expect(spy).toHaveBeenCalledWith('commandForElement', 'nonexistent', 'not found'); - spy.mockRestore(); - }); - }); - - describe('disconnect', () => { - beforeEach(async () => { - fixture = await createFixture( - html` - -
- ` - ); - element = fixture.querySelector('type-command-controller-test-element'); - target = fixture.querySelector('#target'); - await elementIsStable(element); - }); - - it('should not trigger command event after disconnect', async () => { - element.remove(); - let fired = false; - target.addEventListener('command', () => (fired = true)); - element.dispatchEvent(new MouseEvent('click', { bubbles: true })); - expect(fired).toBe(false); - }); - }); -}); diff --git a/projects/core/src/internal/controllers/type-command.controller.ts b/projects/core/src/internal/controllers/type-command.controller.ts deleted file mode 100644 index 116207bfb..000000000 --- a/projects/core/src/internal/controllers/type-command.controller.ts +++ /dev/null @@ -1,96 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import type { ReactiveController, ReactiveElement } from 'lit'; -import type { LegacyDecoratorTarget } from '../types/index.js'; -import { getFlattenedDOMTree } from '../utils/dom.js'; - -/** - * Adds Invoker Commands API support for interactive custom elements. - * https://developer.mozilla.org/en-US/docs/Web/API/Invoker_Commands_API - */ -export function typeCommand(): ClassDecorator { - return (target: LegacyDecoratorTarget) => - target.addInitializer!((instance: T) => new TypeCommandController(instance)); -} - -export type Command = ReactiveElement & - HTMLElement & { - command: string; - commandfor: string | null; - commandForElement: HTMLElement | null; - readOnly: boolean; - disabled: boolean; - }; - -export class TypeCommandController implements ReactiveController { - #trigger: 'click' | 'manual'; - - constructor( - private host: T, - options: TypeCommandControllerOptions = {} - ) { - this.#trigger = options.trigger ?? 'click'; - this.host.addController(this); - } - - get target(): HTMLElement | null { - if (this.host.commandForElement) { - return this.host.commandForElement; - } - - if (!this.host.commandfor) { - return null; - } - - return ( - getFlattenedDOMTree(this.host.getRootNode() as HTMLElement).find(el => el.id === this.host.commandfor) ?? null - ); - } - - async hostUpdated() { - await this.host.updateComplete; - this.#updateListener(); - } - - hostDisconnected() { - this.host.removeEventListener('click', this.#triggerCommand); - } - - #updateListener() { - if (this.#trigger === 'manual' || this.host.readOnly || this.host.disabled) { - this.host.removeEventListener('click', this.#triggerCommand); - } else { - this.host.addEventListener('click', this.#triggerCommand); - } - } - - #triggerCommand = (event: Event) => { - if (event.defaultPrevented) { - return; - } - - this.dispatchCommand(); - }; - - dispatchCommand() { - if (this.host.readOnly || this.host.disabled || !this.host.command) { - return false; - } - - const target = this.target; - if (!target) { - if (this.host.commandfor || this.host.commandForElement) { - console.warn('commandForElement', this.host.commandfor || this.host.commandForElement, 'not found'); - } - return false; - } - - target.dispatchEvent(new globalThis.CommandEvent('command', { command: this.host.command, source: this.host })); - return true; - } -} - -export interface TypeCommandControllerOptions { - trigger?: 'click' | 'manual'; -} diff --git a/projects/core/src/internal/controllers/type-interest.controller.test.ts b/projects/core/src/internal/controllers/type-interest.controller.test.ts deleted file mode 100644 index b6848abf1..000000000 --- a/projects/core/src/internal/controllers/type-interest.controller.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { html, LitElement } from 'lit'; -import { customElement } from 'lit/decorators/custom-element.js'; -import { property } from 'lit/decorators/property.js'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { createFixture, removeFixture, untilEvent, elementIsStable, emulateMouseEnter } from '@internals/testing'; -import { TypeInterestController, type InterestEvent } from '@nvidia-elements/core/internal'; - -@customElement('type-interest-controller-test-element') -class TypeInterestControllerTestElement extends LitElement { - @property({ type: String, reflect: true }) interestfor: string; - @property({ type: Object }) interestForElement: HTMLElement; - @property({ type: Boolean, attribute: 'readonly' }) readOnly = false; - @property({ type: Boolean }) disabled: boolean; - #typeInterestController = new TypeInterestController(this); -} - -describe('type-interest.controller', () => { - let element: TypeInterestControllerTestElement; - let target: HTMLElement; - let fixture: HTMLElement; - - afterEach(() => { - removeFixture(fixture); - }); - - describe('interestfor attribute', () => { - beforeEach(async () => { - fixture = await createFixture( - html` - -
- ` - ); - element = fixture.querySelector('type-interest-controller-test-element'); - target = fixture.querySelector('#target'); - await elementIsStable(element); - }); - - it('should set interestForElement from interestfor attribute', async () => { - emulateMouseEnter(element); - await elementIsStable(element); - expect(element.interestForElement).toBe(target); - }); - - it('should trigger interest event on mouseenter', async () => { - const event = untilEvent(target, 'interest'); - element.dispatchEvent(new MouseEvent('mouseenter')); - const result = await event; - expect(result.source).toBe(element); - }); - - it('should trigger loseinterest event on mouseleave', async () => { - const event = untilEvent(target, 'loseinterest'); - element.dispatchEvent(new MouseEvent('mouseleave')); - const result = await event; - expect(result.source).toBe(element); - }); - - it('should trigger interest event on focus', async () => { - const event = untilEvent(target, 'interest'); - element.dispatchEvent(new FocusEvent('focus')); - const result = await event; - expect(result.source).toBe(element); - }); - - it('should trigger loseinterest event on blur', async () => { - const event = untilEvent(target, 'loseinterest'); - element.dispatchEvent(new FocusEvent('blur')); - const result = await event; - expect(result.source).toBe(element); - }); - }); - - describe('popovertarget legacy behavior', () => { - beforeEach(async () => { - fixture = await createFixture( - html` - -
- ` - ); - element = fixture.querySelector('type-interest-controller-test-element'); - target = fixture.querySelector('#hint-target'); - await elementIsStable(element); - }); - - it('should set interestForElement from popovertarget when target has popover="hint" but used popovertarget attribute', async () => { - emulateMouseEnter(element); - await elementIsStable(element); - expect(element.interestForElement).toBe(target); - }); - - it('should trigger interest event on mouseenter for hint popover', async () => { - const event = untilEvent(target, 'interest'); - element.dispatchEvent(new MouseEvent('mouseenter')); - const result = await event; - expect(result.source).toBe(element); - }); - }); - - describe('popovertarget without hint', () => { - beforeEach(async () => { - fixture = await createFixture( - html` - -
- ` - ); - element = fixture.querySelector('type-interest-controller-test-element'); - target = fixture.querySelector('#auto-target'); - await elementIsStable(element); - }); - - it('should not set interestForElement when popover is not hint', () => { - expect(element.interestForElement).toBeUndefined(); - }); - }); - - describe('interestfor takes precedence over popovertarget', () => { - let hintTarget: HTMLElement; - - beforeEach(async () => { - fixture = await createFixture( - html` - -
-
- ` - ); - element = fixture.querySelector('type-interest-controller-test-element'); - target = fixture.querySelector('#interest-target'); - hintTarget = fixture.querySelector('#hint-target'); - await elementIsStable(element); - }); - - it('should use interestfor over popovertarget', async () => { - emulateMouseEnter(element); - await elementIsStable(element); - expect(element.interestForElement).toBe(target); - expect(element.interestForElement).not.toBe(hintTarget); - }); - }); - - describe('disconnect and reconnect', () => { - beforeEach(async () => { - fixture = await createFixture( - html` - -
- ` - ); - element = fixture.querySelector('type-interest-controller-test-element'); - target = fixture.querySelector('#target'); - await elementIsStable(element); - }); - - it('should not fire duplicate interest events after disconnect and reconnect', async () => { - element.remove(); - fixture.appendChild(element); - await elementIsStable(element); - - let interestCount = 0; - target.addEventListener('interest', () => interestCount++); - element.dispatchEvent(new MouseEvent('mouseenter')); - expect(interestCount).toBe(1); - }); - - it('should continue dispatching interest events after disconnect and reconnect', async () => { - element.remove(); - fixture.appendChild(element); - await elementIsStable(element); - - const event = untilEvent(target, 'interest'); - element.dispatchEvent(new MouseEvent('mouseenter')); - const result = await event; - expect(result.source).toBe(element); - }); - - it('should not dispatch interest events while disconnected', async () => { - element.interestForElement = target; - element.remove(); - - let interestFired = false; - target.addEventListener('interest', () => (interestFired = true)); - element.dispatchEvent(new MouseEvent('mouseenter')); - expect(interestFired).toBe(false); - }); - }); - - describe('no target element', () => { - beforeEach(async () => { - fixture = await createFixture( - html`` - ); - element = fixture.querySelector('type-interest-controller-test-element'); - await elementIsStable(element); - }); - - it('should not have interestForElement when no attributes set', () => { - expect(element.interestForElement).toBeUndefined(); - }); - - it('should not throw when triggering events without target', () => { - expect(() => { - element.dispatchEvent(new MouseEvent('mouseenter')); - element.dispatchEvent(new MouseEvent('mouseleave')); - element.dispatchEvent(new FocusEvent('focus')); - element.dispatchEvent(new FocusEvent('blur')); - }).not.toThrow(); - }); - }); -}); diff --git a/projects/core/src/internal/controllers/type-interest.controller.ts b/projects/core/src/internal/controllers/type-interest.controller.ts deleted file mode 100644 index 14af129db..000000000 --- a/projects/core/src/internal/controllers/type-interest.controller.ts +++ /dev/null @@ -1,105 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import type { ReactiveController, ReactiveElement } from 'lit'; -import type { LegacyDecoratorTarget } from '../types/index.js'; -import { getFlattenedDOMTree } from '../utils/dom.js'; - -export type InterestEvent = Event & { - source: HTMLElement; -}; - -/** - * Adds Interest Invoker Commands API support for interactive custom elements. - * https://developer.mozilla.org/en-US/docs/Web/API/Popover_API/Using_interest_invokers - */ -export function typeInterest(): ClassDecorator { - return (target: LegacyDecoratorTarget) => - target.addInitializer!((instance: T) => new TypeInterestController(instance)); -} - -export type Interest = ReactiveElement & - HTMLElement & { - interestfor: string | null; - interestForElement: HTMLElement | null; - readOnly: boolean; - disabled: boolean; - }; - -export class TypeInterestController implements ReactiveController { - #interestSetupComplete = false; - - constructor(private host: T) { - this.host.addController(this); - } - - async hostConnected() { - await this.#setupInterestEvents(); - } - - async hostUpdated() { - await this.#setupInterestEvents(); - } - - hostDisconnected() { - this.#teardownInterestEvents(); - } - - async #setupInterestEvents() { - await this.host.updateComplete; - if (!this.#interestSetupComplete) { - this.#interestSetupComplete = true; - this.host.addEventListener('mouseenter', this.#triggerInterest); - this.host.addEventListener('mouseleave', this.#triggerLoseInterest); - this.host.addEventListener('focus', this.#triggerInterest); - this.host.addEventListener('blur', this.#triggerLoseInterest); - } - } - - #teardownInterestEvents() { - this.#interestSetupComplete = false; - this.host.removeEventListener('mouseenter', this.#triggerInterest); - this.host.removeEventListener('mouseleave', this.#triggerLoseInterest); - this.host.removeEventListener('focus', this.#triggerInterest); - this.host.removeEventListener('blur', this.#triggerLoseInterest); - } - - #triggerInterest = () => { - this.#updateInterestForElement(); - if (this.host.interestForElement) { - const event = new Event('interest', { cancelable: true }) as InterestEvent; - event.source = this.host; - this.host.interestForElement.dispatchEvent(event); - } - }; - - #triggerLoseInterest = () => { - this.#updateInterestForElement(); - if (this.host.interestForElement) { - const event = new Event('loseinterest', { cancelable: true }) as InterestEvent; - event.source = this.host; - this.host.interestForElement.dispatchEvent(event); - } - }; - - // we can only do this on interaction as its too costly to do this on every getter or update of the interestfor attribute, this diverges from the native behavior of the interestfor attribute - #updateInterestForElement() { - const interestForIdRef = this.host.getAttribute('interestfor'); - if (interestForIdRef && !this.host.interestForElement) { - this.host.interestForElement = getFlattenedDOMTree(this.host.getRootNode() as HTMLElement).find( - el => el.id === interestForIdRef - )!; - } - - // legacy behavior that allows popovertarget to trigger interestfor behavior for hint type popovers - const popovertargetIdRef = this.host.getAttribute('popovertarget'); - if (popovertargetIdRef && !interestForIdRef) { - const target = getFlattenedDOMTree(this.host.getRootNode() as HTMLElement).find( - el => el.id === popovertargetIdRef - ); - if (target && target.popover === 'hint') { - this.host.interestForElement = target; - } - } - } -} diff --git a/projects/core/src/internal/controllers/type-native-popover-trigger.controller.test.ts b/projects/core/src/internal/controllers/type-native-popover-trigger.controller.test.ts deleted file mode 100644 index 46a68c33b..000000000 --- a/projects/core/src/internal/controllers/type-native-popover-trigger.controller.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { html, LitElement } from 'lit'; -import { property } from 'lit/decorators/property.js'; -import { customElement } from 'lit/decorators/custom-element.js'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { createFixture, removeFixture, elementIsStable, untilEvent, emulateClick } from '@internals/testing'; -import { TypeNativePopoverTriggerController } from '@nvidia-elements/core/internal'; - -@customElement('type-native-popover-trigger-controller-test-element') -class TypeNativePopoverTriggerControllerTestElement extends LitElement { - @property({ type: String }) popoverTargetAction: 'show' | 'hide' | 'toggle'; - @property({ type: Object }) popoverTargetElement: HTMLElement; - @property({ type: String }) popovertarget: string; - disabled = false; - _typeNativePopoverTriggerController = new TypeNativePopoverTriggerController(this); -} - -describe('type-native-popover-trigger.controller', () => { - let element: TypeNativePopoverTriggerControllerTestElement; - let popover: HTMLElement; - let fixture: HTMLElement; - - beforeEach(async () => { - fixture = await createFixture( - html` - -
popover
- ` - ); - element = fixture.querySelector( - 'type-native-popover-trigger-controller-test-element' - ); - popover = fixture.querySelector('[popover]'); - await elementIsStable(element); - }); - - afterEach(() => { - removeFixture(fixture); - }); - - it('should trigger popover when clicked', async () => { - expect(popover.matches(':popover-open')).toBe(false); - - const event = untilEvent(element, 'click'); - emulateClick(element); - expect(await event).toBeDefined(); - - await elementIsStable(element); - expect(popover.matches(':popover-open')).toBe(true); - }); - - it('should trigger open when action is set to show', async () => { - element.popoverTargetAction = 'show'; - expect(popover.matches(':popover-open')).toBe(false); - - const event = untilEvent(element, 'click'); - emulateClick(element); - expect(await event).toBeDefined(); - - await elementIsStable(element); - expect(popover.matches(':popover-open')).toBe(true); - }); - - it('should trigger close when action is set to hide', async () => { - element.popoverTargetAction = 'hide'; - popover.showPopover(); - expect(popover.matches(':popover-open')).toBe(true); - - const event = untilEvent(element, 'click'); - emulateClick(element); - expect(await event).toBeDefined(); - - await elementIsStable(element); - expect(popover.matches(':popover-open')).toBe(false); - }); - - it('should trigger popover if reference is used', async () => { - element.popoverTargetElement = popover; - expect(popover.matches(':popover-open')).toBe(false); - - const event = untilEvent(popover, 'toggle'); - emulateClick(element); - expect(await event).toBeDefined(); - - await elementIsStable(element); - expect(popover.matches(':popover-open')).toBe(true); - }); - - it('should trigger popover in cross render roots', async () => { - const shadowHost = document.createElement('div'); - shadowHost.attachShadow({ mode: 'open' }); - shadowHost.shadowRoot.appendChild(popover); - document.body.appendChild(shadowHost); - - const event = untilEvent(popover, 'toggle'); - emulateClick(element); - expect(await event).toBeDefined(); - - await elementIsStable(element); - expect(popover.matches(':popover-open')).toBe(true); - - // Cleanup: close popover and remove shadowHost to prevent test pollution - popover.hidePopover(); - shadowHost.remove(); - }); - - it('should pass source element in toggle event when action is toggle', async () => { - expect(popover.matches(':popover-open')).toBe(false); - - const toggleEvent = untilEvent(popover, 'toggle') as Promise; - emulateClick(element); - const event = await toggleEvent; - - expect(event.source).toBe(element); - }); - - it('should pass source element in toggle event when action is show', async () => { - element.popoverTargetAction = 'show'; - expect(popover.matches(':popover-open')).toBe(false); - - const toggleEvent = untilEvent(popover, 'toggle') as Promise; - emulateClick(element); - const event = await toggleEvent; - - expect(event.source).toBe(element); - }); - - // // source element is not passed back to follow same standard behavior as native button elements - it('should not pass source element in toggle event when action is hide', async () => { - element.popoverTargetAction = 'hide'; - popover.showPopover({ source: element }); - expect(popover.matches(':popover-open')).toBe(true); - - const toggleEvent = untilEvent(popover, 'toggle') as Promise; - emulateClick(element); - const event = await toggleEvent; - - expect(event.source).toBeNull(); - }); - - it('should not throw when clicking without any popover target configured', async () => { - fixture = await createFixture( - html`` - ); - element = fixture.querySelector( - 'type-native-popover-trigger-controller-test-element' - ); - await elementIsStable(element); - - expect(() => emulateClick(element)).not.toThrow(); - }); - - it('should not attempt DOM lookup when popoverTargetElement is already set', async () => { - element.popoverTargetElement = popover; - element.popovertarget = 'nonexistent-id'; - await elementIsStable(element); - - expect(popover.matches(':popover-open')).toBe(false); - emulateClick(element); - await elementIsStable(element); - - expect(popover.matches(':popover-open')).toBe(true); - }); - - it('should not toggle popover when disabled', async () => { - element.disabled = true; - expect(popover.matches(':popover-open')).toBe(false); - - emulateClick(element); - await elementIsStable(element); - - expect(popover.matches(':popover-open')).toBe(false); - }); -}); diff --git a/projects/core/src/internal/controllers/type-native-popover-trigger.controller.ts b/projects/core/src/internal/controllers/type-native-popover-trigger.controller.ts deleted file mode 100644 index c46d20ed0..000000000 --- a/projects/core/src/internal/controllers/type-native-popover-trigger.controller.ts +++ /dev/null @@ -1,60 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import type { ReactiveElement, ReactiveController } from 'lit'; -import type { LegacyDecoratorTarget } from '../types/index.js'; -import { getFlattenedDOMTree } from '../utils/dom.js'; -import { getHostAnchor } from './type-native-popover.utils.js'; - -export interface NativePopoverTrigger extends ReactiveElement { - disabled: boolean; - popoverTargetAction: 'show' | 'hide' | 'toggle'; - popoverTargetElement: HTMLElement | null; - popovertarget: string; - anchor?: HTMLElement; -} - -export function typeNativePopoverTrigger(): ClassDecorator { - return (target: LegacyDecoratorTarget) => - target.addInitializer!((instance: T) => new TypeNativePopoverTriggerController(instance)); -} - -export class TypeNativePopoverTriggerController implements ReactiveController { - constructor(private host: T) { - this.host.addController(this); - } - - hostConnected() { - this.host.addEventListener('click', this.#click); - } - - hostDisconnected() { - this.host.removeEventListener('click', this.#click); - } - - #click = () => { - let source = this.host as HTMLElement; - let popoverTargetElement = this.host.popoverTargetElement; - - // we can only do this on interaction as its too costly to do this on every getter or update of the popovertarget attribute, this diverges from the native behavior of the popovertarget attribute - if (!popoverTargetElement && this.host.popovertarget) { - popoverTargetElement = getFlattenedDOMTree(this.host.getRootNode()).find(e => e.id === this.host.popovertarget)!; - this.host.popoverTargetElement = popoverTargetElement ?? null; - } - - // if popover has explicit anchor, use it as the source - if ((popoverTargetElement as NativePopoverTrigger)?.anchor) { - source = getHostAnchor(popoverTargetElement as NativePopoverTrigger); - } - - if (popoverTargetElement && !this.host.disabled) { - if (this.host.popoverTargetAction === 'hide') { - popoverTargetElement.hidePopover(); - } else if (this.host.popoverTargetAction === 'show') { - popoverTargetElement.showPopover({ source }); - } else { - popoverTargetElement.togglePopover({ source }); - } - } - }; -} diff --git a/projects/core/src/internal/controllers/type-native-popover.controller.ts b/projects/core/src/internal/controllers/type-native-popover.controller.ts index 618952696..c9ba804e0 100644 --- a/projects/core/src/internal/controllers/type-native-popover.controller.ts +++ b/projects/core/src/internal/controllers/type-native-popover.controller.ts @@ -6,8 +6,7 @@ import { clickOutsideElementBounds, generateId, getAttributeListChanges } from ' import { attachInternals } from '../utils/a11y.js'; import { focusElement } from '../utils/focus.js'; import { getHostAnchor, getHostTrigger, hasOpenPopover } from './type-native-popover.utils.js'; -import type { PopoverType } from '../types/index.js'; -import type { InterestEvent } from './type-interest.controller.js'; +import type { InterestEvent, PopoverType } from '../types/index.js'; export interface NativePopover extends ReactiveElement { anchor?: HTMLElement | string; diff --git a/projects/core/src/internal/controllers/type-submit.controller.test.ts b/projects/core/src/internal/controllers/type-submit.controller.test.ts deleted file mode 100644 index 9ad91ffbc..000000000 --- a/projects/core/src/internal/controllers/type-submit.controller.test.ts +++ /dev/null @@ -1,280 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { html, LitElement } from 'lit'; -import { property } from 'lit/decorators/property.js'; -import { customElement } from 'lit/decorators/custom-element.js'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { attachInternals, typeSubmit } from '@nvidia-elements/core/internal'; -import { elementIsStable, createFixture, removeFixture, emulateClick, untilEvent } from '@internals/testing'; - -@typeSubmit() -@customElement('type-submit-controller-test-element') -class TypeSubmitControllerTestElement extends LitElement { - @property({ type: String }) name: string; - @property({ type: String }) value: string; - @property({ type: Boolean }) disabled: boolean; - @property({ type: String }) type: 'button' | 'submit' | 'reset'; - @property({ type: Boolean, attribute: 'readonly' }) readOnly = false; - - #form: HTMLFormElement; - - @property({ type: Object }) - // eslint-disable-next-line @typescript-eslint/related-getter-setter-pairs - get form(): HTMLFormElement | null { - return this.#form ? this.#form : this._internals.form; - } - - set form(form: string | HTMLFormElement) { - if (typeof form === 'string') { - this.#form = (this.getRootNode() as Document | ShadowRoot).getElementById(form) as HTMLFormElement; - } else { - this.#form = form; - } - } - - static formAssociated = true; - - _internals: ElementInternals; - - connectedCallback() { - super.connectedCallback(); - attachInternals(this); - } -} - -describe('type-submit.controller', () => { - let button: TypeSubmitControllerTestElement; - let buttonInForm: TypeSubmitControllerTestElement; - let submitButtonInForm: TypeSubmitControllerTestElement; - let resetButtonInForm: TypeSubmitControllerTestElement; - let fixture: HTMLElement; - let form: HTMLFormElement; - - beforeEach(async () => { - fixture = await createFixture(html` - -
- - - -
- `); - - form = fixture.querySelector('form'); - button = fixture.querySelectorAll('type-submit-controller-test-element')[0]; - buttonInForm = fixture.querySelectorAll('type-submit-controller-test-element')[1]; - resetButtonInForm = fixture.querySelectorAll( - 'type-submit-controller-test-element' - )[2]; - submitButtonInForm = fixture.querySelectorAll( - 'type-submit-controller-test-element' - )[3]; - form.addEventListener('submit', e => e.preventDefault()); - buttonInForm.type = 'button'; - }); - - afterEach(() => { - removeFixture(fixture); - }); - - it('should set the button type to submit if not defined within a form element', async () => { - await elementIsStable(button); - expect(button.type).toBe(undefined); - expect(buttonInForm.type).toBe('button'); - expect(submitButtonInForm.type).toBe('submit'); - }); - - it('should trigger click event when using space key', async () => { - await elementIsStable(button); - expect(button.type).toBe(undefined); - const event = untilEvent(button, 'click'); - button.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space' })); - expect((await event).target).toBe(button); - }); - - it('should add or remove button event listeners when readOnly updates', async () => { - await elementIsStable(submitButtonInForm); - expect(submitButtonInForm.readOnly).toBe(false); - - vi.spyOn(submitButtonInForm, 'removeEventListener'); - submitButtonInForm.readOnly = true; - await elementIsStable(submitButtonInForm); - expect(submitButtonInForm.removeEventListener).toBeCalledTimes(2); - - vi.spyOn(submitButtonInForm, 'addEventListener'); - submitButtonInForm.readOnly = false; - await elementIsStable(submitButtonInForm); - expect(submitButtonInForm.addEventListener).toBeCalledTimes(2); - }); - - it('should trigger submit event when host exists within a form element', async () => { - submitButtonInForm.type = 'submit'; - await elementIsStable(submitButtonInForm); - const event = untilEvent(form, 'submit'); - emulateClick(submitButtonInForm); - expect((await event).type).toBe('submit'); - }); - - it('should trigger submit event with browser default of bubbles: true', async () => { - submitButtonInForm.type = 'submit'; - await elementIsStable(submitButtonInForm); - const event = untilEvent(form, 'submit'); - emulateClick(submitButtonInForm); - expect((await event).bubbles).toBe(true); - }); - - it('should trigger submit event with browser default of cancelable: true', async () => { - submitButtonInForm.type = 'submit'; - await elementIsStable(submitButtonInForm); - const event = untilEvent(form, 'submit'); - emulateClick(submitButtonInForm); - expect((await event).cancelable).toBe(true); - }); - - it('should trigger submit event the event submitter assigned as the host', async () => { - submitButtonInForm.type = 'submit'; - submitButtonInForm.name = 'test-name'; - await elementIsStable(submitButtonInForm); - const event = untilEvent(form, 'submit'); - emulateClick(submitButtonInForm); - expect((((await event) as SubmitEvent).submitter as HTMLButtonElement).name).toBe('test-name'); - // expect(event.submitter).toBe(submitButtonInForm); // https://github.com/WICG/webcomponents/issues/814 - }); - - it('should NOT trigger submit event when host exists within a form element and disabled', async () => { - submitButtonInForm.type = 'submit'; - let count = 0; - form.addEventListener('submit', () => count++); - - const event = untilEvent(form, 'submit'); - emulateClick(submitButtonInForm); - expect((await event).type).toBe('submit'); - expect(count).toBe(1); - - submitButtonInForm.disabled = true; - emulateClick(submitButtonInForm); - await elementIsStable(submitButtonInForm); - expect(count).toBe(1); - }); - - it('should trigger reset event when host exists within a form element', async () => { - resetButtonInForm.type = 'reset'; - await elementIsStable(resetButtonInForm); - const event = untilEvent(form, 'reset'); - emulateClick(resetButtonInForm); - expect((await event).type).toBe('reset'); - }); - - it('should not interact with form elements if type button', async () => { - submitButtonInForm.type = 'button'; - await elementIsStable(submitButtonInForm); - const o = { f: () => null }; - vi.spyOn(o, 'f'); - - form.addEventListener('submit', o.f); - emulateClick(submitButtonInForm); - - const event = new KeyboardEvent('keyup', { key: 'enter' }); - submitButtonInForm.focus(); - submitButtonInForm.dispatchEvent(event); - expect(o.f).not.toHaveBeenCalled(); - }); - - it('should handle dynamic changes for type', async () => { - const o = { f: () => null }; - vi.spyOn(o, 'f'); - - // change default (implicit "submit") to type="button" - submitButtonInForm.type = 'button'; - await elementIsStable(submitButtonInForm); - form.addEventListener('submit', o.f); - emulateClick(submitButtonInForm); - expect(o.f).not.toHaveBeenCalled(); - - // change type="button" to type="submit" - submitButtonInForm.type = 'submit'; - await elementIsStable(submitButtonInForm); - form.removeEventListener('submit', o.f); - emulateClick(submitButtonInForm); - - // change from type="submit" to type="button" - submitButtonInForm.type = 'button'; - await elementIsStable(submitButtonInForm); - form.addEventListener('submit', o.f); - emulateClick(submitButtonInForm); - expect(o.f).not.toHaveBeenCalled(); - }); - - it('should not interact with form elements if disabled', async () => { - submitButtonInForm.disabled = true; - await elementIsStable(submitButtonInForm); - - const o = { f: () => null }; - vi.spyOn(o, 'f'); - - form.addEventListener('submit', o.f); - expect(o.f).not.toHaveBeenCalled(); - }); - - it('should only submit once per click/keypress', async () => { - await elementIsStable(submitButtonInForm); - - const o = { f: () => null }; - vi.spyOn(o, 'f'); - - form.addEventListener('submit', o.f); - expect(o.f).not.toHaveBeenCalled(); - - emulateClick(submitButtonInForm); - await elementIsStable(submitButtonInForm); - expect(o.f).toHaveBeenCalledTimes(1); - }); - - it('should use form property if defined', async () => { - button.form = form; - button.type = 'submit'; - await elementIsStable(button); - - const o = { f: () => null }; - vi.spyOn(o, 'f'); - - form.addEventListener('submit', o.f); - expect(o.f).not.toHaveBeenCalled(); - - emulateClick(button); - await elementIsStable(button); - expect(o.f).toHaveBeenCalledTimes(1); - }); - - it('should be able to access form property from submit event even if form is not in the same document', async () => { - button.form = form; - button.type = 'submit'; - button.name = 'test-name'; - button.value = 'test-value'; - await elementIsStable(button); - const submit = untilEvent(form, 'submit'); - emulateClick(button); - const event = await submit; - expect(event.target).toBe(form); - }); - - it('should use a dynamic native HTMLButtonElement as the submitter due to https://github.com/WICG/webcomponents/issues/814', async () => { - submitButtonInForm.name = 'test-name'; - submitButtonInForm.value = 'test-value'; - await elementIsStable(submitButtonInForm); - const submit = untilEvent(form, 'submit'); - emulateClick(submitButtonInForm); - - const submitter = ((await submit) as SubmitEvent).submitter as HTMLButtonElement; - expect(submitter.form).toBe(form); - expect(submitter.name).toBe('test-name'); - expect(submitter.type).toBe('submit'); - expect(submitter.value).toBe('test-value'); - - // submitter is a native HTMLButtonElement rather than the host custom element - // this is due to https://github.com/WICG/webcomponents/issues/814 - // expect(submitter).toBe(submitButtonInForm); - expect(submitter instanceof HTMLButtonElement).toBe(true); - }); -}); diff --git a/projects/core/src/internal/controllers/type-submit.controller.ts b/projects/core/src/internal/controllers/type-submit.controller.ts deleted file mode 100644 index ba0bd85fa..000000000 --- a/projects/core/src/internal/controllers/type-submit.controller.ts +++ /dev/null @@ -1,104 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import type { ReactiveController, ReactiveElement } from 'lit'; -import type { LegacyDecoratorTarget } from '../types/index.js'; -import { stopEvent } from '../utils/events.js'; -import { onKeys } from '../utils/keynav.js'; - -/** - * Adds Form submit support for interactive custom elements. - * https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/submit_event - */ -export function typeSubmit(): ClassDecorator { - return (target: LegacyDecoratorTarget) => target.addInitializer!((instance: T) => new TypeSubmitController(instance)); -} - -export type Submit = ReactiveElement & - HTMLElement & { - name: string; - value: string; - disabled: boolean; - type: 'button' | 'submit' | 'reset'; - readOnly: boolean; - form?: HTMLFormElement | null; - _internals: ElementInternals; - }; - -export class TypeSubmitController implements ReactiveController { - constructor(private host: T) { - this.host.addController(this); - } - - async hostUpdated() { - await this.host.updateComplete; - this.#setButtonType(); - this.#setupNativeButtonBehavior(); - } - - hostDisconnected() { - this.#removeNativeButtonBehavior(); - } - - #setButtonType() { - if (!this.host.type && !this.host.hasAttribute('type') && this.host.closest('form')) { - this.host.type = 'submit'; - } - } - - #setupNativeButtonBehavior() { - this.#removeNativeButtonBehavior(); - if (!this.host.readOnly && !this.host.disabled) { - this.host.addEventListener('click', this.#triggerNativeButtonBehavior); - this.host.addEventListener('keyup', this.#emulateKeyBoardEventBehavior); - } - } - - #removeNativeButtonBehavior() { - this.host.removeEventListener('click', this.#triggerNativeButtonBehavior); - this.host.removeEventListener('keyup', this.#emulateKeyBoardEventBehavior); - } - - // when submitting forms with Enter key, default submit button receives click event from the form - #emulateKeyBoardEventBehavior = (event: KeyboardEvent) => { - onKeys(['Enter', 'Space'], event, () => this.host.click()); - }; - - #triggerNativeButtonBehavior = (event: Event) => { - if (this.host.disabled) { - stopEvent(event); - return; - } - - if (this.host.type === 'submit' && this.host.form) { - this.#requestSubmit(); - } else if (this.host.type === 'reset') { - this.host.form?.reset(); - } - }; - - #requestSubmit() { - this.#createSubmitter(); - this.host.form!.addEventListener( - 'submit', - () => { - setTimeout(() => this.#submitter.remove(), 0); - }, - { once: true } - ); - this.host.form!.appendChild(this.#submitter); - this.host.form!.requestSubmit(this.#submitter); - } - - #submitter: HTMLButtonElement; - // https://github.com/WICG/webcomponents/issues/814 - #createSubmitter() { - if (!this.#submitter) { - this.#submitter = globalThis.document.createElement('button'); - this.#submitter.type = 'submit'; - this.#submitter.name = this.host.name ?? ''; - this.#submitter.value = this.host.value ?? ''; - this.#submitter.style.display = 'none'; - } - } -} diff --git a/projects/core/src/internal/index.ts b/projects/core/src/internal/index.ts index 84727bc8e..f42c160bd 100644 --- a/projects/core/src/internal/index.ts +++ b/projects/core/src/internal/index.ts @@ -8,25 +8,16 @@ export * from './controllers/audit.controller.js'; export * from './controllers/i18n.controller.js'; export * from './controllers/keynav-grid.controller.js'; export * from './controllers/keynav-list.controller.js'; -export * from './controllers/state-active.controller.js'; -export * from './controllers/state-current.controller.js'; -export * from './controllers/state-disabled.controller.js'; export * from './controllers/state-expanded.controller.js'; export * from './controllers/state-highlighted.controller.js'; export * from './controllers/state-scroll.controller.js'; export * from './controllers/state-selected.controller.js'; -export * from './controllers/state-pressed.controller.js'; export * from './controllers/type-anchor.controller.js'; export * from './controllers/type-closable.controller.js'; -export * from './controllers/type-command.controller.js'; -export * from './controllers/type-interest.controller.js'; export * from './controllers/type-expandable.controller.js'; export * from './controllers/type-selectable.controller.js'; -export * from './controllers/type-button.controller.js'; -export * from './controllers/type-native-popover-trigger.controller.js'; export * from './controllers/type-native-popover.controller.js'; export * from './controllers/type-ssr.controller.js'; -export * from './controllers/type-submit.controller.js'; export * from './controllers/type-touch.controller.js'; export * from './decorators/host-attr.js'; export * from './decorators/scoped-registry.js'; diff --git a/projects/core/src/internal/types/index.ts b/projects/core/src/internal/types/index.ts index 30f0d33e5..e1c8ee104 100644 --- a/projects/core/src/internal/types/index.ts +++ b/projects/core/src/internal/types/index.ts @@ -113,6 +113,10 @@ export type TaskStatus = */ export type PopoverType = 'auto' | 'manual' | 'hint' | 'inline'; +export type InterestEvent = Event & { + source: HTMLElement; +}; + /** Controls how the popover aligns along the edge of its anchor element. * - `start` - Aligns the popover to the beginning edge of the anchor for left or top alignment. * - `end` - Aligns the popover to the ending edge of the anchor for right or bottom alignment. diff --git a/projects/forms/src/internal/controllers/state-current.controller.test.ts b/projects/forms/src/internal/controllers/state-current.controller.test.ts index be6a4e745..dd4e5150a 100644 --- a/projects/forms/src/internal/controllers/state-current.controller.test.ts +++ b/projects/forms/src/internal/controllers/state-current.controller.test.ts @@ -64,6 +64,7 @@ describe('StateCurrentController', () => { element.current = null; element.sync(); + expect(element._internals!.ariaCurrent).toBe(null); expect(element.matches(':state(current)')).toBe(false); }); @@ -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); diff --git a/projects/forms/src/internal/controllers/state-current.controller.ts b/projects/forms/src/internal/controllers/state-current.controller.ts index c282808e8..773cefc14 100644 --- a/projects/forms/src/internal/controllers/state-current.controller.ts +++ b/projects/forms/src/internal/controllers/state-current.controller.ts @@ -24,6 +24,8 @@ export class StateCurrentController implements ReactiveContro hostUpdated() { if (this.host.readOnly) { + this.host._internals!.ariaCurrent = null; + toggleState(this.host._internals!, 'current', false); this.#syncAnchorCurrentAttribute(); return; } @@ -35,9 +37,8 @@ export class StateCurrentController 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)); } @@ -51,7 +52,7 @@ export class StateCurrentController 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; } diff --git a/projects/forms/src/internal/controllers/state-expanded.controller.test.ts b/projects/forms/src/internal/controllers/state-expanded.controller.test.ts index 6e80a7ddd..3946fe41b 100644 --- a/projects/forms/src/internal/controllers/state-expanded.controller.test.ts +++ b/projects/forms/src/internal/controllers/state-expanded.controller.test.ts @@ -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(); diff --git a/projects/forms/src/internal/controllers/state-expanded.controller.ts b/projects/forms/src/internal/controllers/state-expanded.controller.ts index 378ffb607..8025b26fd 100644 --- a/projects/forms/src/internal/controllers/state-expanded.controller.ts +++ b/projects/forms/src/internal/controllers/state-expanded.controller.ts @@ -16,9 +16,8 @@ export class StateExpandedController 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)); } diff --git a/projects/forms/src/internal/controllers/state-pressed.controller.test.ts b/projects/forms/src/internal/controllers/state-pressed.controller.test.ts index 4c350d0e0..0c062a0e6 100644 --- a/projects/forms/src/internal/controllers/state-pressed.controller.test.ts +++ b/projects/forms/src/internal/controllers/state-pressed.controller.test.ts @@ -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(); diff --git a/projects/forms/src/internal/controllers/state-pressed.controller.ts b/projects/forms/src/internal/controllers/state-pressed.controller.ts index d0381c4fe..19d5c639b 100644 --- a/projects/forms/src/internal/controllers/state-pressed.controller.ts +++ b/projects/forms/src/internal/controllers/state-pressed.controller.ts @@ -16,9 +16,8 @@ export class StatePressedController 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)); } diff --git a/projects/forms/src/internal/controllers/state-selected.controller.test.ts b/projects/forms/src/internal/controllers/state-selected.controller.test.ts index aa523b626..2dd062222 100644 --- a/projects/forms/src/internal/controllers/state-selected.controller.test.ts +++ b/projects/forms/src/internal/controllers/state-selected.controller.test.ts @@ -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', () => { @@ -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); diff --git a/projects/forms/src/internal/controllers/state-selected.controller.ts b/projects/forms/src/internal/controllers/state-selected.controller.ts index 9a6e09809..91d4b001a 100644 --- a/projects/forms/src/internal/controllers/state-selected.controller.ts +++ b/projects/forms/src/internal/controllers/state-selected.controller.ts @@ -24,6 +24,8 @@ export class StateSelectedController implements ReactiveCont hostUpdated() { if (this.host.readOnly) { + this.host._internals!.ariaSelected = null; + toggleState(this.host._internals!, 'selected', false); this.#syncAnchorCurrentAttribute(); return; } @@ -35,9 +37,8 @@ export class StateSelectedController 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)); } @@ -51,7 +52,7 @@ export class StateSelectedController 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; } diff --git a/projects/forms/src/internal/controllers/type-native-button.controller.test.ts b/projects/forms/src/internal/controllers/type-submit.controller.test.ts similarity index 68% rename from projects/forms/src/internal/controllers/type-native-button.controller.test.ts rename to projects/forms/src/internal/controllers/type-submit.controller.test.ts index e44ca5037..f2f2b3819 100644 --- a/projects/forms/src/internal/controllers/type-native-button.controller.test.ts +++ b/projects/forms/src/internal/controllers/type-submit.controller.test.ts @@ -5,11 +5,11 @@ import { html } from 'lit'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { createFixture, removeFixture } from '@internals/testing'; -import { TypeNativeButtonController } from './type-native-button.controller.js'; +import { TypeSubmitController } from './type-submit.controller.js'; import type { ButtonType } from '../../mixins/button.types.js'; import type { ReactiveController } from './types.js'; -class NativeButtonBehaviorControllerTestElement extends HTMLElement { +class TypeSubmitControllerTestElement extends HTMLElement { disabled = false; name?: string; readOnly = false; @@ -17,14 +17,14 @@ class NativeButtonBehaviorControllerTestElement extends HTMLElement { #type?: ButtonType; #controllers = new Set(); - _nativeButtonBehaviorController = new TypeNativeButtonController(this); + _typeSubmitController = new TypeSubmitController(this); get form() { return this.closest('form'); } get type() { - return this.#type ?? this._nativeButtonBehaviorController.defaultType; + return this.#type ?? this._typeSubmitController.defaultType; } set type(value: ButtonType | undefined) { @@ -48,11 +48,11 @@ class NativeButtonBehaviorControllerTestElement extends HTMLElement { } } -if (!customElements.get('native-button-behavior-controller-test-element')) { - customElements.define('native-button-behavior-controller-test-element', NativeButtonBehaviorControllerTestElement); +if (!customElements.get('forms-type-submit-controller-test-element')) { + customElements.define('forms-type-submit-controller-test-element', TypeSubmitControllerTestElement); } -describe('NativeButtonBehaviorController', () => { +describe('type-submit.controller', () => { let fixture: HTMLElement; afterEach(() => { @@ -61,10 +61,10 @@ describe('NativeButtonBehaviorController', () => { it('should default to submit when associated with a form and no type is set', async () => { fixture = await createFixture( - html`
` + html`
` ); - const element = fixture.querySelector( - 'native-button-behavior-controller-test-element' + const element = fixture.querySelector( + 'forms-type-submit-controller-test-element' )!; element.sync(); @@ -74,10 +74,10 @@ describe('NativeButtonBehaviorController', () => { it('should trigger clicks from enter and space keyup events', async () => { fixture = await createFixture( - html`` + html`` ); - const element = fixture.querySelector( - 'native-button-behavior-controller-test-element' + const element = fixture.querySelector( + 'forms-type-submit-controller-test-element' )!; const click = vi.spyOn(element, 'click').mockImplementation(() => undefined); @@ -91,11 +91,11 @@ describe('NativeButtonBehaviorController', () => { it('should submit with hidden native submitter data', async () => { fixture = await createFixture( - html`
` + html`
` ); const form = fixture.querySelector('form')!; - const element = fixture.querySelector( - 'native-button-behavior-controller-test-element' + const element = fixture.querySelector( + 'forms-type-submit-controller-test-element' )!; const requestSubmit = vi.spyOn(form, 'requestSubmit').mockImplementation(() => undefined); @@ -116,11 +116,11 @@ describe('NativeButtonBehaviorController', () => { it('should reset the associated form when type is reset', async () => { fixture = await createFixture( - html`
` + html`
` ); const form = fixture.querySelector('form')!; - const element = fixture.querySelector( - 'native-button-behavior-controller-test-element' + const element = fixture.querySelector( + 'forms-type-submit-controller-test-element' )!; const reset = vi.spyOn(form, 'reset').mockImplementation(() => undefined); @@ -133,10 +133,10 @@ describe('NativeButtonBehaviorController', () => { it('should remove listeners when disabled, readonly, or disconnected', async () => { fixture = await createFixture( - html`` + html`` ); - const element = fixture.querySelector( - 'native-button-behavior-controller-test-element' + const element = fixture.querySelector( + 'forms-type-submit-controller-test-element' )!; const click = vi.spyOn(element, 'click').mockImplementation(() => undefined); diff --git a/projects/forms/src/internal/controllers/type-native-button.controller.ts b/projects/forms/src/internal/controllers/type-submit.controller.ts similarity index 79% rename from projects/forms/src/internal/controllers/type-native-button.controller.ts rename to projects/forms/src/internal/controllers/type-submit.controller.ts index a860d81ee..dd5e72b83 100644 --- a/projects/forms/src/internal/controllers/type-native-button.controller.ts +++ b/projects/forms/src/internal/controllers/type-submit.controller.ts @@ -5,7 +5,7 @@ import { onKeys, stopEvent } from '../utils.js'; import type { ButtonType } from '../../mixins/button.types.js'; import type { ReactiveController, ReactiveElement } from './types.js'; -type NativeButtonBehaviorHost = ReactiveElement & { +type SubmitHost = ReactiveElement & { disabled: boolean; form: HTMLFormElement | null | string; name?: string; @@ -14,7 +14,7 @@ type NativeButtonBehaviorHost = ReactiveElement & { value?: string; }; -export class TypeNativeButtonController implements ReactiveController { +export class TypeSubmitController implements ReactiveController { #defaultType: ButtonType | undefined; #submitter: HTMLButtonElement | undefined; #submitterForm: HTMLFormElement | undefined; @@ -29,15 +29,15 @@ export class TypeNativeButtonController impl hostUpdated() { this.#setButtonType(); - this.#removeNativeButtonBehavior(); + this.#removeSubmitBehavior(); if (!this.host.readOnly && !this.host.disabled) { - this.host.addEventListener('click', this.#onNativeButtonClick); - this.host.addEventListener('keyup', this.#onNativeButtonKeyup); + this.host.addEventListener('click', this.#onSubmitClick); + this.host.addEventListener('keyup', this.#onSubmitKeyup); } } hostDisconnected() { - this.#removeNativeButtonBehavior(); + this.#removeSubmitBehavior(); this.#removeSubmitter(); } @@ -47,16 +47,16 @@ export class TypeNativeButtonController impl } } - #removeNativeButtonBehavior() { - this.host.removeEventListener('click', this.#onNativeButtonClick); - this.host.removeEventListener('keyup', this.#onNativeButtonKeyup); + #removeSubmitBehavior() { + this.host.removeEventListener('click', this.#onSubmitClick); + this.host.removeEventListener('keyup', this.#onSubmitKeyup); } - #onNativeButtonKeyup = (event: KeyboardEvent) => { + #onSubmitKeyup = (event: KeyboardEvent) => { onKeys(['Enter', 'Space'], event, () => this.host.click()); }; - #onNativeButtonClick = (event: Event) => { + #onSubmitClick = (event: Event) => { if (this.host.disabled) { stopEvent(event); return; diff --git a/projects/forms/src/internal/types.ts b/projects/forms/src/internal/types.ts index 642c73dc7..10fdcc532 100644 --- a/projects/forms/src/internal/types.ts +++ b/projects/forms/src/internal/types.ts @@ -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; @@ -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; diff --git a/projects/forms/src/mixins/button.ts b/projects/forms/src/mixins/button.ts index 14a7861e1..5c92d3756 100644 --- a/projects/forms/src/mixins/button.ts +++ b/projects/forms/src/mixins/button.ts @@ -5,7 +5,7 @@ import { TypeAnchorController } from '../internal/controllers/type-anchor.contro import { TypeButtonController } from '../internal/controllers/type-button.controller.js'; import { TypeCommandController } from '../internal/controllers/type-command.controller.js'; import { TypeInterestInvokerController } from '../internal/controllers/type-interest-invoker.controller.js'; -import { TypeNativeButtonController } from '../internal/controllers/type-native-button.controller.js'; +import { TypeSubmitController } from '../internal/controllers/type-submit.controller.js'; import { TypePopoverTriggerController } from '../internal/controllers/type-popover-trigger.controller.js'; import { StateActiveController } from '../internal/controllers/state-active.controller.js'; import { StateCurrentController } from '../internal/controllers/state-current.controller.js'; @@ -188,7 +188,7 @@ export function ButtonFormControlMixin( } get type(): ButtonType { - return (this.#type ?? this._typeNativeButtonController?.defaultType) as ButtonType; + return (this.#type ?? this._typeSubmitController?.defaultType) as ButtonType; } set type(value: ButtonType | unknown) { @@ -295,7 +295,7 @@ export function ButtonFormControlMixin( protected _controllers?: Set; protected _typeAnchorController = new TypeAnchorController(this); protected _typeButtonController = new TypeButtonController(this); - protected _typeNativeButtonController = new TypeNativeButtonController(this); + protected _typeSubmitController = new TypeSubmitController(this); protected _typeCommandController = new TypeCommandController(this); protected _typeInterestInvokerController = new TypeInterestInvokerController(this); protected _typePopoverTriggerController = new TypePopoverTriggerController(this); diff --git a/projects/forms/src/mixins/checkbox.test.ts b/projects/forms/src/mixins/checkbox.test.ts index 25ecef716..934a4e43a 100644 --- a/projects/forms/src/mixins/checkbox.test.ts +++ b/projects/forms/src/mixins/checkbox.test.ts @@ -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; diff --git a/projects/forms/src/mixins/control.test.ts b/projects/forms/src/mixins/control.test.ts index 61b9a8dcd..8578aaf74 100644 --- a/projects/forms/src/mixins/control.test.ts +++ b/projects/forms/src/mixins/control.test.ts @@ -36,6 +36,7 @@ describe('FormControlMixin', () => { afterEach(() => { removeFixture(fixture); + vi.restoreAllMocks(); }); it('should define element', () => { @@ -253,10 +254,14 @@ describe('FormControlMixin', () => { expect((element.form!.elements as unknown as Record)['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 () => { diff --git a/projects/forms/src/mixins/control.ts b/projects/forms/src/mixins/control.ts index c72f87ce8..73bac7467 100644 --- a/projects/forms/src/mixins/control.ts +++ b/projects/forms/src/mixins/control.ts @@ -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'; @@ -438,7 +444,7 @@ export function FormControlMixin(HTMLE customElements.define('ui-slider-test-element', SliderTestElement); +class CustomSliderDefaultsTestElement extends SliderFormControlMixin(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); + describe('SliderFormControlMixin', () => { let fixture: HTMLElement; let element: SliderTestElement; @@ -50,6 +69,33 @@ describe('SliderFormControlMixin', () => { expect(element.step).toBe(1); }); + it('should support custom slider defaults', async () => { + const customFixture = await createFixture(html` +
+ +
+ `); + const customElement = customFixture.querySelector( + 'ui-custom-slider-defaults-test-element' + )!; + const customForm = customFixture.querySelector('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 submit numeric form data', () => { element.value = 10; diff --git a/projects/forms/src/validators/index.test.ts b/projects/forms/src/validators/index.test.ts index 529bde92f..b434be488 100644 --- a/projects/forms/src/validators/index.test.ts +++ b/projects/forms/src/validators/index.test.ts @@ -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); + }); }); }); @@ -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(''); }); @@ -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'); }); diff --git a/projects/forms/src/validators/index.ts b/projects/forms/src/validators/index.ts index d74878322..3f2fb3274 100644 --- a/projects/forms/src/validators/index.ts +++ b/projects/forms/src/validators/index.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { validateSchema } from '../internal/schema.js'; -import type { FormControl, ValidatorResult } from '../internal/types.js'; +import type { FormControl, FormControlInstance, ValidatorResult } from '../internal/types.js'; /** * Given a json schema, check the value against the schema @@ -19,16 +19,13 @@ import type { FormControl, ValidatorResult } from '../internal/types.js'; * - Array * - Enum */ -export function valueSchemaValidator(value: unknown, element: FormControl): ValidatorResult { - const metadata = (element as any).constructor.metadata; // eslint-disable-line @typescript-eslint/no-explicit-any +export function valueSchemaValidator(value: unknown, element: FormControlInstance): ValidatorResult { + const metadata = (element.constructor as FormControl).metadata; return validateSchema(metadata.valueSchema, value); } -export function requiredValidator(value: unknown, element: FormControl): ValidatorResult { - if ( - (element as unknown as { required: boolean }).required && - (value === undefined || value === null || value === '') - ) { +export function requiredValidator(value: unknown, element: FormControlInstance): ValidatorResult { + if (element.required && (value === undefined || value === null || value === '')) { return { validity: { valueMissing: true, valid: false }, message: 'This field is required' }; } return { validity: { valid: true }, message: '' }; diff --git a/projects/internals/metadata/static/tests.json b/projects/internals/metadata/static/tests.json index 656af1f42..fb81ab897 100644 --- a/projects/internals/metadata/static/tests.json +++ b/projects/internals/metadata/static/tests.json @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:64fc95332ca81aa7c401eb8dbba274d13f8831dc69ae7ffb06c3a505054dd648 -size 2040320 +oid sha256:719dec18cdc189bfba8cb130c3fa098be51e62f3ea62365715c7d594a8f92c69 +size 2408084