diff --git a/projects/core/src/checkbox/checkbox.test.ts b/projects/core/src/checkbox/checkbox.test.ts index 7c29bdc9b..a9fd4376e 100644 --- a/projects/core/src/checkbox/checkbox.test.ts +++ b/projects/core/src/checkbox/checkbox.test.ts @@ -89,6 +89,17 @@ describe(`${Checkbox.metadata.tag} - control base behavior`, () => { expect((e as Event).composed).toBe(true); }); + it('should reset checked state to the native default', async () => { + const input = fixture.querySelector('input'); + input.defaultChecked = true; + input.checked = false; + + element.reset(); + await elementIsStable(element); + + expect(input.checked).toBe(true); + }); + it('should disconnect observers when removed from DOM', async () => { const input = fixture.querySelector('input'); expect(element.matches(':state(checked)')).toBe(false); diff --git a/projects/core/src/combobox/combobox.test.lighthouse.ts b/projects/core/src/combobox/combobox.test.lighthouse.ts index ef0ff3637..3f43bae42 100644 --- a/projects/core/src/combobox/combobox.test.lighthouse.ts +++ b/projects/core/src/combobox/combobox.test.lighthouse.ts @@ -25,7 +25,7 @@ describe('combobox lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(36); + expect(report.payload.javascript.kb).toBeLessThan(36.1); }); test('combobox multi select with large dataset should meet lighthouse benchmarks', async () => { @@ -42,6 +42,6 @@ describe('combobox lighthouse report', () => { expect(report.scores.performance).toBeGreaterThanOrEqual(96); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(36); + expect(report.payload.javascript.kb).toBeLessThan(36.1); }); }); diff --git a/projects/core/src/dialog/dialog.test.lighthouse.ts b/projects/core/src/dialog/dialog.test.lighthouse.ts index 1543cbb57..cbce21313 100644 --- a/projects/core/src/dialog/dialog.test.lighthouse.ts +++ b/projects/core/src/dialog/dialog.test.lighthouse.ts @@ -25,6 +25,6 @@ describe('dialog lighthouse report', () => { expect(report.scores.performance).toBeGreaterThan(97); // bfcache expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(24.8); + expect(report.payload.javascript.kb).toBeLessThan(24.9); }); }); diff --git a/projects/core/src/drawer/drawer.test.lighthouse.ts b/projects/core/src/drawer/drawer.test.lighthouse.ts index 729d17330..3962d706f 100644 --- a/projects/core/src/drawer/drawer.test.lighthouse.ts +++ b/projects/core/src/drawer/drawer.test.lighthouse.ts @@ -18,6 +18,6 @@ describe('drawer lighthouse report', () => { expect(report.scores.performance).toBeGreaterThan(98); // bfcache expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(25.3); + expect(report.payload.javascript.kb).toBeLessThan(25.4); }); }); diff --git a/projects/core/src/dropdown-group/dropdown-group.test.lighthouse.ts b/projects/core/src/dropdown-group/dropdown-group.test.lighthouse.ts index 6beeba3a0..e69346721 100644 --- a/projects/core/src/dropdown-group/dropdown-group.test.lighthouse.ts +++ b/projects/core/src/dropdown-group/dropdown-group.test.lighthouse.ts @@ -29,6 +29,6 @@ describe('dropdown-group lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(24.7); + expect(report.payload.javascript.kb).toBeLessThan(24.8); }); }); diff --git a/projects/core/src/format-relative-time/format-relative-time.test.ts b/projects/core/src/format-relative-time/format-relative-time.test.ts index d4c9220cb..970a616be 100644 --- a/projects/core/src/format-relative-time/format-relative-time.test.ts +++ b/projects/core/src/format-relative-time/format-relative-time.test.ts @@ -408,5 +408,19 @@ describe(FormatRelativeTime.metadata.tag, () => { const time = element.shadowRoot!.querySelector('time'); expect(time!.textContent!.trim()).toBe('2023-07-27T12:00:00.000Z'); }); + + it('should fall back to raw string for relative values outside Intl range', async () => { + vi.useRealTimers(); + const now = vi.spyOn(Date, 'now').mockReturnValue(Number.NEGATIVE_INFINITY); + + element.date = '2023-07-27T12:00:00.000Z'; + element.requestUpdate(); + await elementIsStable(element); + + const time = element.shadowRoot!.querySelector('time'); + expect(time!.textContent!.trim()).toBe('2023-07-27T12:00:00.000Z'); + + now.mockRestore(); + }); }); }); diff --git a/projects/core/src/format-relative-time/format-relative-time.ts b/projects/core/src/format-relative-time/format-relative-time.ts index 79b6e45b6..f47652aeb 100644 --- a/projects/core/src/format-relative-time/format-relative-time.ts +++ b/projects/core/src/format-relative-time/format-relative-time.ts @@ -95,7 +95,7 @@ export class FormatRelativeTime extends LitElement { const value = Math.round(absDiff / UNIT_DIVISORS[unit]); if (value < max) return { value: sign * value, unit }; } - throw new Error('format-relative-time: no relative time threshold matched'); + return { value: sign * Math.round(absDiff / UNIT_DIVISORS.year), unit: 'year' }; } #computeExplicitUnit(diffMs: number, unit: TimeUnitOption): number { diff --git a/projects/core/src/forms/control-group/control-group.css b/projects/core/src/forms/control-group/control-group.css index 0da55ca54..487e182b8 100644 --- a/projects/core/src/forms/control-group/control-group.css +++ b/projects/core/src/forms/control-group/control-group.css @@ -5,7 +5,7 @@ --color: var(--nve-sys-interaction-field-color); --opacity: 1; --label-color: var(--nve-sys-interaction-field-color); - --label-text-transform: capitalize; + --label-text-transform: none; --label-font-weight: var(--nve-ref-font-weight-medium); --label-font-size: var(--nve-ref-font-size-100); --_label-width: var(--label-width, 180px); diff --git a/projects/core/src/forms/control-group/control-group.ts b/projects/core/src/forms/control-group/control-group.ts index f9932ed62..522dc00a9 100644 --- a/projects/core/src/forms/control-group/control-group.ts +++ b/projects/core/src/forms/control-group/control-group.ts @@ -12,7 +12,12 @@ import { associateControlGroup } from '@nvidia-elements/core/internal'; import type { ControlMessage } from '../control-message/control-message.js'; -import { setupControlStatusStates, setupControlGroupStates, inputQuery } from '../utils/states.js'; +import { + setupControlStatusStates, + setupControlGroupStates, + inputQuery, + type ControlStateCleanup +} from '../utils/states.js'; import { setupControlLayoutStates } from '../utils/layout.js'; import styles from './control-group.css?inline'; @@ -53,7 +58,7 @@ export class ControlGroup extends LitElement { return this.querySelectorAll ? Array.from(this.querySelectorAll('nve-control-message')) : []; } - #observers: (MutationObserver | ResizeObserver)[] = []; + #observers: (MutationObserver | ResizeObserver | ControlStateCleanup)[] = []; static styles = useStyles([styles]); diff --git a/projects/core/src/forms/control/control.css b/projects/core/src/forms/control/control.css index a81077c6a..98161c28b 100644 --- a/projects/core/src/forms/control/control.css +++ b/projects/core/src/forms/control/control.css @@ -6,7 +6,7 @@ --accent-color: var(--nve-sys-accent-secondary-background); --color: var(--nve-sys-interaction-field-color); --label-color: var(--nve-sys-interaction-field-color); - --label-text-transform: capitalize; + --label-text-transform: none; --label-font-weight: var(--nve-ref-font-weight-medium); --label-font-size: var(--nve-ref-font-size-100); --control-width: 100%; @@ -18,10 +18,6 @@ contain: content; } -:host([nve-control='inline']) { - --control-height: auto; -} - ::slotted(*) { color-scheme: var(--nve-sys-color-scheme); font-family: inherit; @@ -38,10 +34,6 @@ align-self: center !important; } -::slotted(label::first-letter) { - text-transform: capitalize; -} - :host(:state(disabled)), :host(:state(disabled)) ::slotted(nve-control-message:not([status])) { --cursor: not-allowed; @@ -194,6 +186,9 @@ slot[name='messages'] { /** inline controls */ :host([nve-control='inline']) { + --control-height: auto; + width: fit-content; + [internal-host] { grid-template-areas: 'input input input'; diff --git a/projects/core/src/forms/control/control.test.ts b/projects/core/src/forms/control/control.test.ts index cf3332971..b1510a2a8 100644 --- a/projects/core/src/forms/control/control.test.ts +++ b/projects/core/src/forms/control/control.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { html } from 'lit'; -import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; import { createFixture, removeFixture, elementIsStable, untilEvent } from '@internals/testing'; import { Control, ControlMessage } from '@nvidia-elements/core/forms'; import '@nvidia-elements/core/forms/define.js'; @@ -167,6 +167,33 @@ describe(Control.metadata.tag, () => { expect(getComputedStyle(message).display).toBe('block'); expect(validationMessage.hidden).toBe(true); }); + + it('should not duplicate form reset listeners after reconnect', async () => { + removeFixture(fixture); + fixture = await createFixture(html` +
+ + + + message + +
+ `); + const form = fixture.querySelector('form'); + element = fixture.querySelector(Control.metadata.tag); + await elementIsStable(element); + + element.remove(); + form.appendChild(element); + await elementIsStable(element); + element.shadowRoot!.dispatchEvent(new Event('slotchange')); + await elementIsStable(element); + + const requestUpdate = vi.spyOn(element, 'requestUpdate'); + form.dispatchEvent(new Event('reset')); + + expect(requestUpdate).toHaveBeenCalledTimes(2); + }); }); describe(`${Control.metadata.tag}: custom`, () => { diff --git a/projects/core/src/forms/control/control.ts b/projects/core/src/forms/control/control.ts index f7ad27ebf..6845c1f31 100644 --- a/projects/core/src/forms/control/control.ts +++ b/projects/core/src/forms/control/control.ts @@ -21,12 +21,20 @@ import { setupControlValidationStates, setupControlStates, setupControlStatusStates, - inputQuery + inputQuery, + type ControlStateCleanup } from '../utils/states.js'; import { setupControlLayoutStates } from '../utils/layout.js'; import globalStyles from './control.global.css?inline'; import styles from './control.css?inline'; +interface ResettableControl { + getAttribute: Element['getAttribute']; + value: string; +} + +type ControlInput = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | ResettableControl; + /** * @element nve-control * @description Wraps a form input with its associated label and validation messages, managing layout and accessibility associations. @@ -118,7 +126,7 @@ export class Control extends LitElement { /** @private */ declare _internals: ElementInternals; - #observers: (MutationObserver | ResizeObserver)[] = []; + #observers: (MutationObserver | ResizeObserver | ControlStateCleanup)[] = []; protected _associateDatalist = true; @@ -181,15 +189,42 @@ export class Control extends LitElement { /** Resets control value to initial attribute value and clears any active validation rules. */ reset() { - this.input.value = this.input.getAttribute('value') ?? ''; + this.#resetInputValue(this.input as ControlInput); this.requestUpdate(); this.dispatchEvent(new CustomEvent('reset', { bubbles: true, composed: true })); } - #setupInput() { - setupControlValidationStates(this, this.#messages); + #resetInputValue(input: ControlInput) { + if (input instanceof HTMLSelectElement) { + this.#resetSelectValue(input); + return; + } + + if (this.#isCheckedInput(input)) { + input.checked = input.defaultChecked; + input.indeterminate = false; + return; + } + input.value = + input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement + ? input.defaultValue + : (input.getAttribute('value') ?? ''); + } + + #resetSelectValue(input: HTMLSelectElement) { + const hasDefaultSelection = Array.from(input.options).some(option => option.defaultSelected); + Array.from(input.options).forEach(option => (option.selected = option.defaultSelected)); + if (!input.multiple && !hasDefaultSelection) input.selectedIndex = 0; + } + + #isCheckedInput(input: ControlInput): input is HTMLInputElement { + return input instanceof HTMLInputElement && (input.type === 'checkbox' || input.type === 'radio'); + } + + #setupInput() { this.#observers.push( + ...setupControlValidationStates(this, this.#messages), ...setupControlStates(this), ...setupControlStatusStates(this, this.#messages), setupControlLayoutStates(this), diff --git a/projects/core/src/forms/utils/states.ts b/projects/core/src/forms/utils/states.ts index 8039f2fbd..2cb5d7bfe 100644 --- a/projects/core/src/forms/utils/states.ts +++ b/projects/core/src/forms/utils/states.ts @@ -12,6 +12,9 @@ import { ControlMessage } from '../control-message/control-message.js'; import type { Control } from '../control/control.js'; export const inputQuery = 'input, select, selectmenu, textarea, [nve-control]'; +export interface ControlStateCleanup { + disconnect: () => void; +} /** * Adds validation states to custom element @@ -20,6 +23,8 @@ export const inputQuery = 'input, select, selectmenu, textarea, [nve-control]'; */ // eslint-disable-next-line max-lines-per-function export function setupControlValidationStates(control: Control, messages: ControlMessage[]) { + const cleanups: ControlStateCleanup[] = []; + if ( !control.input.form?.noValidate && !control.input.formNoValidate && @@ -47,16 +52,16 @@ export function setupControlValidationStates(control: Control, messages: Control hideAllValidationMessages(messages); }; - control.input.addEventListener('blur', () => { + const onBlur = () => { control.input.checkValidity(); updateValidityState(); - }); + }; - control.input.addEventListener('input', () => { + const onInput = () => { updateValidityState(); - }); + }; - control.input.addEventListener('invalid', () => { + const onInvalid = () => { if (messages.find(m => m.error)) { hideAllValidationMessages(messages); showActiveValidationMessages(control, messages); @@ -65,13 +70,21 @@ export function setupControlValidationStates(control: Control, messages: Control control.status = 'error'; control._internals.states.delete('valid'); control._internals.states.add('invalid'); - }); + }; + + cleanups.push( + addCleanupListener(control.input, 'blur', onBlur), + addCleanupListener(control.input, 'input', onInput), + addCleanupListener(control.input, 'invalid', onInvalid), + addCleanupListener(control, 'reset', resetValidityState) + ); - control.addEventListener('reset', () => resetValidityState()); - control.input.form?.addEventListener('reset', () => resetValidityState()); + if (control.input.form) { + cleanups.push(addCleanupListener(control.input.form, 'reset', resetValidityState)); + } } - control.shadowRoot!.addEventListener('slotchange', () => { + const onSlotChange = () => { const current = Array.from(control.querySelectorAll(ControlMessage.metadata.tag)); control._internals.states.delete('valid'); control._internals.states.delete('invalid'); @@ -80,7 +93,10 @@ export function setupControlValidationStates(control: Control, messages: Control } else { control._internals.states.add('valid'); } - }); + }; + cleanups.push(addCleanupListener(control.shadowRoot!, 'slotchange', onSlotChange)); + + return cleanups; } /** @@ -92,31 +108,12 @@ export function setupControlValidationStates(control: Control, messages: Control * :state(dirty) user modified the form control */ export function setupControlStates(control: Control) { - const observers: MutationObserver[] = []; + const observers: ControlStateCleanup[] = []; const states = control._internals.states; control.input.checked ? states.add('checked') : states.delete('checked'); control.input.indeterminate ? states.add('indeterminate') : states.delete('indeterminate'); - control.input.addEventListener('focus', () => control._internals.states.add('focus')); - control.input.addEventListener('input', () => control._internals.states.add('dirty')); - control.input.addEventListener('blur', () => { - control._internals.states.add('touched'); - control._internals.states.delete('focus'); - }); - - control.input.getRootNode().addEventListener('change', (e: Event) => { - if ((e.target as HTMLInputElement).name === control.input?.name) { - control.input.checked ? states.add('checked') : states.delete('checked'); - } - }); - - control.input.form?.addEventListener('reset', () => { - control._internals.states.delete('touched'); - control._internals.states.delete('dirty'); - control._internals.states.delete('error'); - control._internals.states.delete('success'); - control.requestUpdate(); - }); + observers.push(...addControlInteractionListeners(control)); observers.push( getElementUpdate(control.input, 'readonly', value => (value === '' ? true : value) ? states.add('readonly') : states.delete('readonly') @@ -134,6 +131,44 @@ export function setupControlStates(control: Control) { return observers; } +function addControlInteractionListeners(control: Control): ControlStateCleanup[] { + const cleanups: ControlStateCleanup[] = []; + const states = control._internals.states; + const onFocus = () => control._internals.states.add('focus'); + const onInput = () => control._internals.states.add('dirty'); + const onBlur = () => { + control._internals.states.add('touched'); + control._internals.states.delete('focus'); + }; + + const onRootChange = (e: Event) => { + if ((e.target as HTMLInputElement).name === control.input?.name) { + control.input.checked ? states.add('checked') : states.delete('checked'); + } + }; + + const onFormReset = () => { + control._internals.states.delete('touched'); + control._internals.states.delete('dirty'); + control._internals.states.delete('error'); + control._internals.states.delete('success'); + control.requestUpdate(); + }; + + cleanups.push( + addCleanupListener(control.input, 'focus', onFocus), + addCleanupListener(control.input, 'input', onInput), + addCleanupListener(control.input, 'blur', onBlur), + addCleanupListener(control.input.getRootNode(), 'change', onRootChange) + ); + + if (control.input.form) { + cleanups.push(addCleanupListener(control.input.form, 'reset', onFormReset)); + } + + return cleanups; +} + /** * Adds control group interaction states to custom element * :state(disabled) any form control within group is in a disabled state @@ -158,7 +193,7 @@ function toggleControlGroupDisabledState(controlGroup: ControlGroup) { */ export function setupControlStatusStates(control: Control | ControlGroup, messages: ControlMessage[]) { updateControlStatusState(control, messages.find(m => !m.hidden)!); - const observers: MutationObserver[] = []; + const observers: ControlStateCleanup[] = []; observers.push( getAttributeListChanges(control, ['hidden', 'status'], mutation => { const target = mutation.target as ControlMessage; @@ -168,7 +203,7 @@ export function setupControlStatusStates(control: Control | ControlGroup, messag }) ); - control.shadowRoot!.addEventListener('slotchange', () => { + const onSlotChange = () => { const current = Array.from(control.querySelectorAll(ControlMessage.metadata.tag)); const message = current.find(m => m.status && !m.hidden); control._internals.states.delete('error'); @@ -176,11 +211,19 @@ export function setupControlStatusStates(control: Control | ControlGroup, messag if (message) { control._internals.states.add(message.status!); } - }); + }; + observers.push(addCleanupListener(control.shadowRoot!, 'slotchange', onSlotChange)); return observers; } +function addCleanupListener(target: EventTarget, type: string, listener: EventListener): ControlStateCleanup { + target.addEventListener(type, listener); + return { + disconnect: () => target.removeEventListener(type, listener) + }; +} + export function updateControlStatusState(control: Control | ControlGroup, message: ControlMessage) { control._internals.states.delete('error'); control._internals.states.delete('success'); diff --git a/projects/core/src/index.test.lighthouse.ts b/projects/core/src/index.test.lighthouse.ts index e897e4437..486f1f4c9 100644 --- a/projects/core/src/index.test.lighthouse.ts +++ b/projects/core/src/index.test.lighthouse.ts @@ -15,7 +15,7 @@ describe('lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.requests['index.js'].kb).toBeLessThan(130.1); + expect(report.payload.javascript.requests['index.js'].kb).toBeLessThan(130.4); // if sudden drop in size, check vite bundle config and bundle demo to ensure side effects are properly preserved expect(report.payload.javascript.requests['index.js'].kb).toBeGreaterThan(120); diff --git a/projects/core/src/internal/controllers/keynav-grid.controller.test.ts b/projects/core/src/internal/controllers/keynav-grid.controller.test.ts index 8d192d8ea..74922e3de 100644 --- a/projects/core/src/internal/controllers/keynav-grid.controller.test.ts +++ b/projects/core/src/internal/controllers/keynav-grid.controller.test.ts @@ -152,6 +152,20 @@ describe('grid-key-navigation.controller', () => { expect(element.keynavGridConfig.cells[2].tabIndex).toBe(0); }); + it('should not duplicate listeners after reconnect', async () => { + const listener = vi.fn(); + element.addEventListener('nve-key-change', listener); + + element.remove(); + fixture.appendChild(element); + await elementIsStable(element); + element.keynavGridConfig.cells[0].focus(); + + element.keynavGridConfig.grid.dispatchEvent(new KeyboardEvent('keydown', { code: 'ArrowRight' })); + + expect(listener).toHaveBeenCalledOnce(); + }); + it('should support arrow key navigation', async () => { element.keynavGridConfig.cells[0].dispatchEvent( new KeyboardEvent('keydown', { code: 'ArrowRight', bubbles: true }) diff --git a/projects/core/src/internal/controllers/keynav-grid.controller.ts b/projects/core/src/internal/controllers/keynav-grid.controller.ts index 3b810c912..6e26514bd 100644 --- a/projects/core/src/internal/controllers/keynav-grid.controller.ts +++ b/projects/core/src/internal/controllers/keynav-grid.controller.ts @@ -44,6 +44,7 @@ export function keyNavigationGrid export class KeyNavigationGridController implements ReactiveController { #observers: MutationObserver[] = []; + #grid?: HTMLElement; get #config() { return { @@ -72,10 +73,13 @@ export class KeyNavigationGridController this.#updateCellActivation(e)); - this.#config.grid.addEventListener('keydown', (e: KeyboardEvent) => this.#keynavCell(e)); - this.#config.grid.addEventListener('mouseup', (e: MouseEvent) => this.#clickCell(e)); + this.#grid.addEventListener('keyup', this.#onKeyUp); + this.#grid.addEventListener('keydown', this.#onKeyDown); + this.#grid.addEventListener('mouseup', this.#onMouseUp); this.#observers.push( onChildListMutation( this.host, @@ -86,6 +90,11 @@ export class KeyNavigationGridController o?.disconnect()); + this.#observers.length = 0; + this.#grid?.removeEventListener('keyup', this.#onKeyUp); + this.#grid?.removeEventListener('keydown', this.#onKeyDown); + this.#grid?.removeEventListener('mouseup', this.#onMouseUp); + this.#grid = undefined; } #clickCell(e: MouseEvent) { @@ -148,6 +157,12 @@ export class KeyNavigationGridController this.#updateCellActivation(e); + + #onKeyDown = (e: KeyboardEvent) => this.#keynavCell(e); + + #onMouseUp = (e: MouseEvent) => this.#clickCell(e); } function getGridDelta(code: KeynavCode | string, dir: string): { dx: number; dy: number } | null { diff --git a/projects/core/src/internal/controllers/keynav-list.controller.test.ts b/projects/core/src/internal/controllers/keynav-list.controller.test.ts index a0054dc19..d43cc7807 100644 --- a/projects/core/src/internal/controllers/keynav-list.controller.test.ts +++ b/projects/core/src/internal/controllers/keynav-list.controller.test.ts @@ -5,7 +5,7 @@ import { html, LitElement } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { property } from 'lit/decorators/property.js'; import { elementIsStable, createFixture, removeFixture, emulateClick } from '@internals/testing'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { keyNavigationList } from '@nvidia-elements/core/internal'; @customElement('keynav-list-test-element') @@ -70,6 +70,22 @@ describe('keynav-list.controller', () => { expect(element.keynavListConfig.items[2].tabIndex).toBe(0); }); + it('should not duplicate listeners after reconnect', async () => { + const listener = vi.fn(); + element.addEventListener('nve-key-change', listener); + + element.remove(); + fixture.appendChild(element); + await elementIsStable(element); + element.keynavListConfig.items[0].focus(); + + element.keynavListConfig.items[0].dispatchEvent( + new KeyboardEvent('keydown', { code: 'ArrowRight', bubbles: true, composed: true }) + ); + + expect(listener).toHaveBeenCalledOnce(); + }); + it('should support horizontal arrow key navigation', async () => { await elementIsStable(element); element.keynavListConfig.items[0].dispatchEvent( diff --git a/projects/core/src/internal/controllers/keynav-list.controller.ts b/projects/core/src/internal/controllers/keynav-list.controller.ts index 31a10a2cb..b3939bb75 100644 --- a/projects/core/src/internal/controllers/keynav-list.controller.ts +++ b/projects/core/src/internal/controllers/keynav-list.controller.ts @@ -51,9 +51,15 @@ export class KeyNavigationListController this.#clickItem(e)); - this.host.addEventListener('keydown', (e: KeyboardEvent) => this.#focusItem(e)); + this.host.addEventListener('pointerup', this.#onPointerUp); + this.host.addEventListener('keydown', this.#onKeyDown); + } + + hostDisconnected() { + this.host.removeEventListener('pointerup', this.#onPointerUp); + this.host.removeEventListener('keydown', this.#onKeyDown); } #initializeTabIndex() { @@ -114,6 +120,10 @@ export class KeyNavigationListController this.#clickItem(e); + + #onKeyDown = (e: KeyboardEvent) => this.#focusItem(e); } interface KeyListConfig { diff --git a/projects/core/src/internal/controllers/type-native-popover.controller.test.ts b/projects/core/src/internal/controllers/type-native-popover.controller.test.ts index 7773791a7..f67dc1cca 100644 --- a/projects/core/src/internal/controllers/type-native-popover.controller.test.ts +++ b/projects/core/src/internal/controllers/type-native-popover.controller.test.ts @@ -4,7 +4,7 @@ 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createFixture, removeFixture, elementIsStable, untilEvent, emulateClick } from '@internals/testing'; import type { PopoverAlign, PopoverPosition } from '@nvidia-elements/core/internal'; import { popoverStyles, TypeNativePopoverController, useStyles } from '@nvidia-elements/core/internal'; @@ -115,6 +115,21 @@ describe('type-popover.controller', () => { expect(element.matches(':popover-open')).toBe(false); }); + it('should not duplicate open events after reconnect', async () => { + const listener = vi.fn(); + element.addEventListener('open', listener); + + element.remove(); + fixture.appendChild(element); + await elementIsStable(element); + await new Promise(r => requestAnimationFrame(r)); + + element.dispatchEvent(new ToggleEvent('toggle', { oldState: 'closed', newState: 'open' })); + await elementIsStable(element); + + expect(listener).toHaveBeenCalledOnce(); + }); + it('should open popover when trigger is activated', async () => { const event = untilEvent(element, 'open'); emulateClick(button); @@ -635,6 +650,35 @@ describe('type-popover.controller - legacy popovertarget hint', () => { expect(element.matches(':popover-open')).toBe(true); }); + it('should not duplicate legacy hint trigger listeners after updates', async () => { + removeFixture(fixture); + fixture = await createFixture(html` + anchor + + `); + element = fixture.querySelector( + 'type-native-popover-controller-test-element' + ); + button = fixture.querySelector(Button.metadata.tag); + await elementIsStable(element); + await elementIsStable(button); + + const showPopover = vi.spyOn(element, 'showPopover'); + element.requestUpdate(); + await elementIsStable(element); + element.requestUpdate(); + await elementIsStable(element); + + button.dispatchEvent(new MouseEvent('mouseenter')); + + expect(showPopover).toHaveBeenCalledOnce(); + }); + it('should find shadow root active triggers', async () => { await elementIsStable(element); expect(element.matches(':popover-open')).toBe(false); 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..0eede55e7 100644 --- a/projects/core/src/internal/controllers/type-native-popover.controller.ts +++ b/projects/core/src/internal/controllers/type-native-popover.controller.ts @@ -70,102 +70,30 @@ export class TypeNativePopoverController implements Rea }; } - // eslint-disable-next-line max-lines-per-function async hostConnected() { attachInternals(this.host); this.host.popover = this.host.popoverType ?? null; await this.host.updateComplete; + if (!this.host.isConnected) return; + this.host.setAttribute('nve-popover', ''); this.#updateLegacyTriggers(); this.#setupLegacyTriggers(); // eslint-disable-line @typescript-eslint/no-floating-promises this.#setupModalLightDismiss(); this.host.inert = this.host.matches(':not(:popover-open)') && !!this.#nativeTriggers.length; - this.host.addEventListener('beforetoggle', e => { - if (e.newState === 'open') { - this.host._internals!.states.add('transition-start'); - } - }); - - this.host.addEventListener('toggle', (e: ToggleEvent) => { - if (this.host.behaviorTrigger) { - this.host.hidden = e.newState === 'closed'; - } - - if (e.newState === 'open' && this.host.closeTimeout) { - this.#setCloseTimeout(); - } - - if (e.newState === 'closed') { - this.#clearInterestTimeout(); - this.#clearCloseTimeout(); - } - - this.host.inert = this.host.matches(':not(:popover-open)'); - - if (this.host.modal) { - this.#toggleFocus(e.newState === 'open', e.target as HTMLElement); - } - - this.host.dispatchEvent( - new CustomEvent(e.newState === 'open' && e.oldState !== 'open' ? 'open' : 'close', { - bubbles: true, - composed: true, - detail: { trigger: e.source } - }) - ); - }); - - // https://developer.mozilla.org/en-US/docs/Web/API/Invoker_Commands_API#creating_declarative_popovers - this.host.addEventListener('command', ((e: CommandEvent) => { - if (e.command === 'toggle-popover') { - this.host.togglePopover({ source: e.source as HTMLElement }); - } - - if (e.command === 'hide-popover') { - this.host.hidePopover(); - this.#clearInterestTimeout(); - } - - if (e.command === 'show-popover') { - this.host.showPopover({ source: e.source as HTMLElement }); - } - }) as EventListener); - - // https://developer.mozilla.org/en-US/docs/Web/API/Popover_API/Using_interest_invokers - this.host.addEventListener('interest', ((e: InterestEvent) => { - const isCustomElement = e.source?.localName.includes('-'); - if (isCustomElement) { - const interestDelayStart = this.host.openDelay ?? this.#parseInterestDelay(); - if (interestDelayStart) { - this.#interestTimeout = setTimeout(() => { - if (this.host.isConnected) { - this.host.showPopover({ source: e.source as HTMLElement }); - } - }, interestDelayStart); - } else { - this.host.showPopover({ source: e.source as HTMLElement }); - } - } - }) as EventListener); - - this.host.addEventListener('loseinterest', ((e: InterestEvent) => { - const isCustomElement = e.source?.localName.includes('-'); - if (isCustomElement) { - this.host.hidePopover(); - } - - if (this.#interestTimeout) { - clearTimeout(this.#interestTimeout); - this.#interestTimeout = null; - } - }) as EventListener); + this.host.addEventListener('beforetoggle', this.#onBeforeToggle); + this.host.addEventListener('toggle', this.#onToggle as EventListener); + this.host.addEventListener('command', this.#onCommand as EventListener); + this.host.addEventListener('interest', this.#onInterest as EventListener); + this.host.addEventListener('loseinterest', this.#onLoseInterest as EventListener); } #interestTimeout: ReturnType | null = null; #closeTimeout: ReturnType | null = null; #observers: MutationObserver[] = []; #previousLegacyTrigger: HTMLButtonElement | null = null; + #hintTrigger: HTMLButtonElement | null = null; async hostUpdated() { this.host.popover = this.host.popoverType ?? null; @@ -174,6 +102,15 @@ export class TypeNativePopoverController implements Rea hostDisconnected() { this.#observers.forEach(observer => observer.disconnect()); + this.#observers.length = 0; + this.host.removeEventListener('beforetoggle', this.#onBeforeToggle); + this.host.removeEventListener('toggle', this.#onToggle as EventListener); + this.host.removeEventListener('command', this.#onCommand as EventListener); + this.host.removeEventListener('interest', this.#onInterest as EventListener); + this.host.removeEventListener('loseinterest', this.#onLoseInterest as EventListener); + this.host.removeEventListener('pointerdown', this.#onPointerDown); + this.host.removeEventListener('pointerup', this.#onPointerUp); + this.#removeHintTrigger(); this.#clearInterestTimeout(); this.#clearCloseTimeout(); } @@ -212,24 +149,10 @@ export class TypeNativePopoverController implements Rea #pointerdownWithinModal = false; #setupModalLightDismiss() { - this.host.addEventListener('pointerdown', e => { - if (this.host.modal && this.host.matches(':popover-open')) { - this.#pointerdownWithinModal = clickOutsideElementBounds(e, this.host); - } - }); - - this.host.addEventListener('pointerup', e => { - if ( - this.#pointerdownWithinModal && - this.host.popoverDismissible && - this.host.modal && - this.host.matches(':popover-open') && - !hasOpenPopover(this.host) && - clickOutsideElementBounds(e, this.host) - ) { - this.host.hidePopover(); - } - }); + this.host.removeEventListener('pointerdown', this.#onPointerDown); + this.host.removeEventListener('pointerup', this.#onPointerUp); + this.host.addEventListener('pointerdown', this.#onPointerDown); + this.host.addEventListener('pointerup', this.#onPointerUp); } get #legacyHostTrigger(): HTMLElement | null { @@ -273,25 +196,41 @@ export class TypeNativePopoverController implements Rea #updateLegacyTriggers() { const trigger = this.#legacyHostTrigger as HTMLButtonElement; - // Clean up previous trigger if it changed if (this.#previousLegacyTrigger && this.#previousLegacyTrigger !== trigger) { - this.#previousLegacyTrigger.popoverTargetElement = null; - this.#previousLegacyTrigger.removeAttribute('popovertarget'); + this.#clearLegacyTrigger(this.#previousLegacyTrigger); } - // if not a hint type setup native popovertarget - if (trigger) { - if (this.host.popoverType === 'hint') { - trigger.addEventListener('mouseenter', () => this.host.showPopover({ source: trigger as HTMLElement })); - trigger.addEventListener('mouseleave', () => this.host.hidePopover()); - trigger.addEventListener('focusout', () => this.host.hidePopover()); - } else { - this.host.id = this.host.id ? this.host.id : generateId(); - trigger.popoverTargetElement = this.host; - trigger.setAttribute('popovertarget', this.host.id); - } - this.#previousLegacyTrigger = trigger; + if (this.#hintTrigger && this.#hintTrigger !== trigger) { + this.#removeHintTrigger(); + } + + if (!trigger) { + this.#previousLegacyTrigger = null; + return; } + + if (this.host.popoverType === 'hint') { + this.#setupHintTrigger(trigger); + } else { + this.#setupPopoverTargetTrigger(trigger); + } + this.#previousLegacyTrigger = trigger; + } + + #setupHintTrigger(trigger: HTMLButtonElement) { + if (this.#hintTrigger === trigger) return; + + trigger.addEventListener('mouseenter', this.#onHintMouseEnter); + trigger.addEventListener('mouseleave', this.#onHintMouseLeave); + trigger.addEventListener('focusout', this.#onHintMouseLeave); + this.#hintTrigger = trigger; + } + + #setupPopoverTargetTrigger(trigger: HTMLButtonElement) { + this.#removeHintTrigger(); + this.host.id = this.host.id ? this.host.id : generateId(); + trigger.popoverTargetElement = this.host; + trigger.setAttribute('popovertarget', this.host.id); } #toggleFocus(open: boolean, target: HTMLElement) { @@ -304,4 +243,121 @@ export class TypeNativePopoverController implements Rea focusElement(target); } } + + #clearLegacyTrigger(trigger: HTMLButtonElement) { + trigger.popoverTargetElement = null; + trigger.removeAttribute('popovertarget'); + if (this.#hintTrigger === trigger) { + this.#removeHintTrigger(); + } + } + + #removeHintTrigger() { + this.#hintTrigger?.removeEventListener('mouseenter', this.#onHintMouseEnter); + this.#hintTrigger?.removeEventListener('mouseleave', this.#onHintMouseLeave); + this.#hintTrigger?.removeEventListener('focusout', this.#onHintMouseLeave); + this.#hintTrigger = null; + } + + #onBeforeToggle = (e: ToggleEvent) => { + if (e.newState === 'open') { + this.host._internals!.states.add('transition-start'); + } + }; + + #onToggle = (e: ToggleEvent) => { + if (this.host.behaviorTrigger) { + this.host.hidden = e.newState === 'closed'; + } + + if (e.newState === 'open' && this.host.closeTimeout) { + this.#setCloseTimeout(); + } + + if (e.newState === 'closed') { + this.#clearInterestTimeout(); + this.#clearCloseTimeout(); + } + + this.host.inert = this.host.matches(':not(:popover-open)'); + + if (this.host.modal) { + this.#toggleFocus(e.newState === 'open', e.target as HTMLElement); + } + + this.host.dispatchEvent( + new CustomEvent(e.newState === 'open' && e.oldState !== 'open' ? 'open' : 'close', { + bubbles: true, + composed: true, + detail: { trigger: e.source } + }) + ); + }; + + #onCommand = (e: CommandEvent) => { + if (e.command === 'toggle-popover') { + this.host.togglePopover({ source: e.source as HTMLElement }); + } + + if (e.command === 'hide-popover') { + this.host.hidePopover(); + this.#clearInterestTimeout(); + } + + if (e.command === 'show-popover') { + this.host.showPopover({ source: e.source as HTMLElement }); + } + }; + + #onInterest = (e: InterestEvent) => { + const isCustomElement = e.source?.localName.includes('-'); + if (isCustomElement) { + const interestDelayStart = this.host.openDelay ?? this.#parseInterestDelay(); + if (interestDelayStart) { + this.#interestTimeout = setTimeout(() => { + if (this.host.isConnected) { + this.host.showPopover({ source: e.source as HTMLElement }); + } + }, interestDelayStart); + } else { + this.host.showPopover({ source: e.source as HTMLElement }); + } + } + }; + + #onLoseInterest = (e: InterestEvent) => { + const isCustomElement = e.source?.localName.includes('-'); + if (isCustomElement) { + this.host.hidePopover(); + } + + this.#clearInterestTimeout(); + }; + + #onPointerDown = (e: PointerEvent) => { + if (this.host.modal && this.host.matches(':popover-open')) { + this.#pointerdownWithinModal = clickOutsideElementBounds(e, this.host); + } + }; + + #onPointerUp = (e: PointerEvent) => { + if ( + this.#pointerdownWithinModal && + this.host.popoverDismissible && + this.host.modal && + this.host.matches(':popover-open') && + !hasOpenPopover(this.host) && + clickOutsideElementBounds(e, this.host) + ) { + this.host.hidePopover(); + } + }; + + #onHintMouseEnter = (e: MouseEvent) => { + this.host.showPopover({ source: e.currentTarget as HTMLElement }); + }; + + #onHintMouseLeave = () => { + this.host.hidePopover(); + }; } diff --git a/projects/core/src/internal/services/log.service.test.ts b/projects/core/src/internal/services/log.service.test.ts index e273cad5e..3cbd43c7a 100644 --- a/projects/core/src/internal/services/log.service.test.ts +++ b/projects/core/src/internal/services/log.service.test.ts @@ -14,6 +14,7 @@ describe('LogService', () => { afterEach(() => { window.NVE_ELEMENTS.state.env = 'production'; + vi.unstubAllGlobals(); vi.restoreAllMocks(); }); diff --git a/projects/core/src/internal/services/log.service.ts b/projects/core/src/internal/services/log.service.ts index 2d1c34880..da0376789 100644 --- a/projects/core/src/internal/services/log.service.ts +++ b/projects/core/src/internal/services/log.service.ts @@ -27,6 +27,6 @@ export class LogService { } static #dispatch(type: 'warn' | 'error', value: string, ...args: unknown[]) { - globalThis.document.dispatchEvent(new CustomEvent('NVE_ELEMENTS_LOG', { detail: { type, value, args } })); + globalThis.document?.dispatchEvent(new CustomEvent('NVE_ELEMENTS_LOG', { detail: { type, value, args } })); } } diff --git a/projects/core/src/internal/utils/dom.test.ts b/projects/core/src/internal/utils/dom.test.ts index a58afeed6..62b3c414f 100644 --- a/projects/core/src/internal/utils/dom.test.ts +++ b/projects/core/src/internal/utils/dom.test.ts @@ -271,6 +271,19 @@ describe('getPropertyChanges', () => { expect(option.selected).toBe(false); }); + it('should stop calling callback after cleanup', () => { + const input = document.createElement('input'); + const spy = vi.fn(); + const cleanup = getPropertyChanges(input, 'value', spy); + + input.value = 'before cleanup'; + cleanup?.(); + input.value = 'after cleanup'; + + expect(spy).toHaveBeenCalledOnce(); + expect(input.value).toBe('after cleanup'); + }); + it('should not throw when the property has no prototype descriptor', () => { const div = document.createElement('div'); @@ -319,6 +332,22 @@ describe('getElementUpdate', () => { element.id = 'foo'; expect(await update).toBe('foo'); }); + + it('should stop triggering property updates after disconnect', () => { + const input = document.createElement('input'); + const spy = vi.fn(); + const observer = getElementUpdate(input, 'value', spy); + + spy.mockClear(); + input.value = 'before disconnect'; + expect(spy).toHaveBeenCalledOnce(); + + spy.mockClear(); + observer.disconnect(); + input.value = 'after disconnect'; + + expect(spy).not.toHaveBeenCalled(); + }); }); describe('clickOutsideElementBounds', () => { diff --git a/projects/core/src/internal/utils/dom.ts b/projects/core/src/internal/utils/dom.ts index 9c6aafe9a..e26b55d00 100644 --- a/projects/core/src/internal/utils/dom.ts +++ b/projects/core/src/internal/utils/dom.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { GlobalStateService } from '../services/global.service.js'; +import { LogService } from '../services/log.service.js'; import type { ElementDefinition } from '../types/index.js'; import { isFocusable } from './focus.js'; @@ -91,6 +92,15 @@ export function getPropertyChanges(element: HTMLElement, key: string, callback: callback(val); } }); + + return () => { + if (own) { + Object.defineProperty(element, key, own); + return; + } + + Reflect.deleteProperty(element, key); + }; } /* used for cases of needing to know a property update outside of lit, example a native input value prop change */ @@ -101,8 +111,16 @@ export function getElementUpdate(element: HTMLElement, key: string, callback: (v callback((element as unknown as Record)[key]); } - getPropertyChanges(element, key, callback); - return getAttributeChanges(element, key, val => callback(val)); + const cleanupPropertyChanges = getPropertyChanges(element, key, callback); + const observer = getAttributeChanges(element, key, val => callback(val)); + const disconnect = observer.disconnect.bind(observer); + + observer.disconnect = () => { + cleanupPropertyChanges?.(); + disconnect(); + }; + + return observer; } export function clickOutsideElementBounds(event: PointerEvent | MouseEvent, element: HTMLElement) { @@ -243,11 +261,21 @@ export function endOfScrollBox(element: HTMLElement, offset = 0) { } export async function openEyeDropper(): Promise { - return await new ( - globalThis as unknown as { EyeDropper: new () => { open: () => Promise<{ sRGBHex: string }> } } - ).EyeDropper() - .open() - .then((color: { sRGBHex: string }) => color.sRGBHex); + const EyeDropperConstructor = ( + globalThis as unknown as { EyeDropper?: new () => { open: () => Promise<{ sRGBHex: string }> } } + ).EyeDropper; + + if (!EyeDropperConstructor) { + LogService.warn('EyeDropper API is unavailable'); + return ''; + } + + try { + return (await new EyeDropperConstructor().open()).sRGBHex; + } catch (e) { + LogService.warn(`EyeDropper selection failed: ${(e as Error).message}`); + return ''; + } } /** diff --git a/projects/core/src/menu/menu.test.lighthouse.ts b/projects/core/src/menu/menu.test.lighthouse.ts index 90f5e2391..baaa45926 100644 --- a/projects/core/src/menu/menu.test.lighthouse.ts +++ b/projects/core/src/menu/menu.test.lighthouse.ts @@ -21,6 +21,6 @@ describe('menu lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(15.9); + expect(report.payload.javascript.kb).toBeLessThan(16); }); }); diff --git a/projects/core/src/notification/notification.test.lighthouse.ts b/projects/core/src/notification/notification.test.lighthouse.ts index e4fce2485..29da683bf 100644 --- a/projects/core/src/notification/notification.test.lighthouse.ts +++ b/projects/core/src/notification/notification.test.lighthouse.ts @@ -17,6 +17,6 @@ describe('notification lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(25.1); + expect(report.payload.javascript.kb).toBeLessThan(25.3); }); }); diff --git a/projects/core/src/page/page-panel/page-panel.test.axe.ts b/projects/core/src/page/page-panel/page-panel.test.axe.ts new file mode 100644 index 000000000..4e67a8911 --- /dev/null +++ b/projects/core/src/page/page-panel/page-panel.test.axe.ts @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { describe, expect, it } from 'vitest'; +import { createFixture, elementIsStable, removeFixture } from '@internals/testing'; +import { runAxe } from '@internals/testing/axe'; +import { PagePanel } from '@nvidia-elements/core/page'; +import '@nvidia-elements/core/page/define.js'; +import '@nvidia-elements/core/icon-button/define.js'; + +describe(PagePanel.metadata.tag, () => { + it('should pass axe check', async () => { + const fixture = await createFixture(html` + + + panel heading + + + panel content + panel footer + + + `); + + await elementIsStable(fixture.querySelector(PagePanel.metadata.tag)); + const results = await runAxe([PagePanel.metadata.tag]); + expect(results.violations.length).toBe(0); + removeFixture(fixture); + }); +}); diff --git a/projects/core/src/page/page-panel/page-panel.test.ssr.ts b/projects/core/src/page/page-panel/page-panel.test.ssr.ts new file mode 100644 index 000000000..14cc8390a --- /dev/null +++ b/projects/core/src/page/page-panel/page-panel.test.ssr.ts @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { describe, expect, it } from 'vitest'; +import { ssrRunner } from '@internals/vite'; +import { PagePanel } from '@nvidia-elements/core/page'; +import '@nvidia-elements/core/page/define.js'; + +describe(PagePanel.metadata.tag, () => { + it('should pass baseline ssr check', async () => { + const result = await ssrRunner.render(html` + + + + `); + + expect(result.includes('shadowroot="open"')).toBe(true); + expect(result.includes(PagePanel.metadata.tag)).toBe(true); + expect(result.includes('command="--open"')).toBe(true); + }); +}); diff --git a/projects/core/src/pagination/pagination.test.lighthouse.ts b/projects/core/src/pagination/pagination.test.lighthouse.ts index 6e09f908c..60e92cc59 100644 --- a/projects/core/src/pagination/pagination.test.lighthouse.ts +++ b/projects/core/src/pagination/pagination.test.lighthouse.ts @@ -16,6 +16,6 @@ describe('pagination lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(37.9); + expect(report.payload.javascript.kb).toBeLessThan(38.3); }); }); diff --git a/projects/core/src/preferences-input/preferences-input.test.lighthouse.ts b/projects/core/src/preferences-input/preferences-input.test.lighthouse.ts index 5b771defe..300b7a917 100644 --- a/projects/core/src/preferences-input/preferences-input.test.lighthouse.ts +++ b/projects/core/src/preferences-input/preferences-input.test.lighthouse.ts @@ -16,6 +16,6 @@ describe('preferences-input lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(32.3); + expect(report.payload.javascript.kb).toBeLessThan(32.6); }); }); diff --git a/projects/core/src/radio/radio.test.ts b/projects/core/src/radio/radio.test.ts index f7f2c374e..4ed8f1c66 100644 --- a/projects/core/src/radio/radio.test.ts +++ b/projects/core/src/radio/radio.test.ts @@ -38,6 +38,17 @@ describe(Radio.metadata.tag, () => { await elementIsStable(element); expect(element.matches(':state(checked)')).toBe(true); }); + + it('should reset checked state to the native default', async () => { + const input = fixture.querySelector('input'); + input.defaultChecked = true; + input.checked = false; + + element.reset(); + await elementIsStable(element); + + expect(input.checked).toBe(true); + }); }); describe(`${Radio.metadata.tag} - radio group`, () => { diff --git a/projects/core/src/select/select.test.ts b/projects/core/src/select/select.test.ts index b273bd092..710d8e4a0 100644 --- a/projects/core/src/select/select.test.ts +++ b/projects/core/src/select/select.test.ts @@ -267,6 +267,25 @@ describe(Select.metadata.tag, () => { expect(element.shadowRoot.querySelectorAll(Tag.metadata.tag).length).toBe(3); }); + it('should stop observing removed option selected state changes', async () => { + select.multiple = true; + await elementIsStable(element); + + const option = document.createElement('option'); + option.value = '4'; + option.textContent = 'Option 4'; + select.appendChild(option); + await elementIsStable(element); + + option.remove(); + await elementIsStable(element); + + const requestUpdate = vi.spyOn(element, 'requestUpdate'); + option.selected = true; + + expect(requestUpdate).not.toHaveBeenCalled(); + }); + it('should update tags when using multiple select and options change', async () => { select.multiple = true; select.options[0].selected = true; @@ -370,6 +389,18 @@ describe(Select.metadata.tag, () => { expect(element.matches(':state(multiple)')).toBe(true); }); + it('should remove host :state(multiple) state when multiple is cleared', async () => { + select.multiple = true; + element.requestUpdate(); + await elementIsStable(element); + expect(element.matches(':state(multiple)')).toBe(true); + + select.multiple = false; + element.requestUpdate(); + await elementIsStable(element); + expect(element.matches(':state(multiple)')).toBe(false); + }); + it('should hide tags and display label when multiple is used and tags overflow container', async () => { expect(element.matches(':state(multiple-overflow)')).toBe(false); select.multiple = true; @@ -404,6 +435,29 @@ describe(Select.metadata.tag, () => { expect(element.matches(':state(size)')).toBe(true); }); + it('should remove host :state(size) state when size is cleared', async () => { + select.size = 2; + element.requestUpdate(); + await elementIsStable(element); + expect(element.matches(':state(size)')).toBe(true); + + select.size = 0; + element.requestUpdate(); + await elementIsStable(element); + expect(element.matches(':state(size)')).toBe(false); + expect(element.style.getPropertyValue('--size')).toBe(''); + }); + + it('should reset selected option to the native default', async () => { + select.options[1].defaultSelected = true; + select.value = '3'; + + element.reset(); + await elementIsStable(element); + + expect(select.value).toBe('2'); + }); + it('should apply disabled styles to tags when disabled with multiple selection', async () => { select.multiple = true; select.options[0].selected = true; diff --git a/projects/core/src/select/select.ts b/projects/core/src/select/select.ts index 7564dd007..16a04e257 100644 --- a/projects/core/src/select/select.ts +++ b/projects/core/src/select/select.ts @@ -179,12 +179,24 @@ export class Select extends Control { ); } - #trackedOptions = new Set(); + #optionObservers = new Map(); + #syncOptionSelectedStates() { + const options = new Set(this.#options); + + this.#optionObservers.forEach((observer, option) => { + if (!options.has(option)) { + observer.disconnect(); + this.#optionObservers.delete(option); + this.#observers = this.#observers.filter(o => o !== observer); + } + }); + this.#options.forEach(o => { - if (!this.#trackedOptions.has(o)) { - this.#trackedOptions.add(o); - this.#observers.push(getElementUpdate(o, 'selected', () => this.requestUpdate())); + if (!this.#optionObservers.has(o)) { + const observer = getElementUpdate(o, 'selected', () => this.requestUpdate()); + this.#optionObservers.set(o, observer); + this.#observers.push(observer); } }); } @@ -192,6 +204,7 @@ export class Select extends Control { disconnectedCallback() { super.disconnectedCallback(); this.#observers.forEach(observer => observer.disconnect()); + this.#optionObservers.clear(); } async updated(props: PropertyValues) { @@ -199,10 +212,15 @@ export class Select extends Control { if (this.#select?.size && this.#select?.size !== 0) { this._internals.states.add('size'); this.style.setProperty('--size', `${this.#select?.size + 0.75}`); + } else { + this._internals.states.delete('size'); + this.style.removeProperty('--size'); } if (this.#select?.multiple && this.#select?.size === 0) { this._internals.states.add('multiple'); + } else { + this._internals.states.delete('multiple'); } } diff --git a/projects/core/src/sparkline/sparkline.utils.test.ts b/projects/core/src/sparkline/sparkline.utils.test.ts index dff3a4b9d..528e57327 100644 --- a/projects/core/src/sparkline/sparkline.utils.test.ts +++ b/projects/core/src/sparkline/sparkline.utils.test.ts @@ -121,6 +121,11 @@ describe('sparkline.utils', () => { expect(toLinePath(points, 'smooth', 120)).toContain('C '); }); + it('uses linear line path fallback for invalid interpolation', () => { + const invalidInterpolation = 'invalid' as unknown as Parameters[1]; + expect(toLinePath(points, invalidInterpolation, 120)).toBe(toLinePath(points, 'linear', 120)); + }); + it('returns an empty line path for empty points', () => { expect(toLinePath([], 'linear', 120)).toBe(''); }); @@ -149,6 +154,11 @@ describe('sparkline.utils', () => { expect(smoothArea.endsWith('Z')).toBe(true); }); + it('uses linear area path fallback for invalid interpolation', () => { + const invalidInterpolation = 'invalid' as unknown as Parameters[1]; + expect(toAreaPath(points, invalidInterpolation)).toBe(toAreaPath(points, 'linear')); + }); + it('returns an empty area path for empty points', () => { expect(toAreaPath([], 'linear')).toBe(''); }); diff --git a/projects/core/src/sparkline/sparkline.utils.ts b/projects/core/src/sparkline/sparkline.utils.ts index 887be65cb..aecb49eb4 100644 --- a/projects/core/src/sparkline/sparkline.utils.ts +++ b/projects/core/src/sparkline/sparkline.utils.ts @@ -121,10 +121,8 @@ export function toLinePath(points: Point[], interpolation: Interpolation, viewWi return toSmoothOpenPath(points); case 'linear': return toLinearOpenPath(points); - default: { - const exhaustiveCheck: never = interpolation; - throw new Error(`Unhandled interpolation: ${exhaustiveCheck}`); - } + default: + return toLinearOpenPath(points); } } @@ -142,10 +140,9 @@ export function toAreaPath(points: Point[], interpolation: Interpolation, viewHe case 'linear': openPath = toLinearOpenPath(points); break; - default: { - const exhaustiveCheck: never = interpolation; - throw new Error(`Unhandled interpolation: ${exhaustiveCheck}`); - } + default: + openPath = toLinearOpenPath(points); + break; } const last = points[points.length - 1]!; diff --git a/projects/core/src/toast/toast.test.lighthouse.ts b/projects/core/src/toast/toast.test.lighthouse.ts index 2b9e15918..91a647423 100644 --- a/projects/core/src/toast/toast.test.lighthouse.ts +++ b/projects/core/src/toast/toast.test.lighthouse.ts @@ -17,6 +17,6 @@ describe('toast lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(24.5); + expect(report.payload.javascript.kb).toBeLessThan(24.6); }); }); diff --git a/projects/core/src/tree/tree.test.lighthouse.ts b/projects/core/src/tree/tree.test.lighthouse.ts index e19ac3a7b..2a2dcbc02 100644 --- a/projects/core/src/tree/tree.test.lighthouse.ts +++ b/projects/core/src/tree/tree.test.lighthouse.ts @@ -27,6 +27,6 @@ describe('tree lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(29.5); + expect(report.payload.javascript.kb).toBeLessThan(29.7); }); });