diff --git a/projects/core/src/internal/base/button.test.ts b/projects/core/src/internal/base/button.test.ts index 3530234104..6685973ea3 100644 --- a/projects/core/src/internal/base/button.test.ts +++ b/projects/core/src/internal/base/button.test.ts @@ -79,7 +79,7 @@ describe('base button', () => { }); it('should remove aria-disabled if readonly', async () => { - element.readonly = true; + element.readOnly = true; await elementIsStable(element); expect(element._internals.ariaDisabled).toBe(null); expect(element.matches(':state(disabled)')).toBe(false); @@ -117,7 +117,7 @@ describe('base button', () => { expect(element._internals.ariaExpanded).toBe('true'); expect(element.matches(':state(expanded)')).toBe(true); - element.readonly = true; + element.readOnly = true; await elementIsStable(element); expect(element._internals.ariaExpanded).toBe(null); expect(element.matches(':state(expanded)')).toBe(false); @@ -149,7 +149,7 @@ describe('base button', () => { expect(element._internals.ariaPressed).toBe('true'); expect(element.matches(':state(pressed)')).toBe(true); - element.readonly = true; + element.readOnly = true; await elementIsStable(element); expect(element._internals.ariaPressed).toBe(null); expect(element.matches(':state(pressed)')).toBe(false); @@ -172,13 +172,36 @@ describe('base button', () => { }); it('should remove tabindex and role if readonly', async () => { - element.readonly = true; + element.readOnly = true; await elementIsStable(element); expect(element.tabIndex).toBe(-1); expect(element._internals.role).toBe('none'); expect(element.getAttribute('role')).toBe(null); }); + it('should map readonly attribute to readOnly property', async () => { + element.setAttribute('readonly', ''); + await elementIsStable(element); + expect(element.readOnly).toBe(true); + }); + + it('should reflect readOnly property to readonly attribute', async () => { + element.readOnly = true; + await elementIsStable(element); + expect(element.hasAttribute('readonly')).toBe(true); + + element.readOnly = false; + await elementIsStable(element); + expect(element.hasAttribute('readonly')).toBe(false); + }); + + it('should support deprecated readonly property alias', async () => { + element.readonly = true; + await elementIsStable(element); + expect(element.readOnly).toBe(true); + expect(element.hasAttribute('readonly')).toBe(true); + }); + it('should set the button type to submit if not defined within a form element', async () => { await elementIsStable(element); expect(element.type).toBe(undefined); @@ -186,17 +209,17 @@ describe('base button', () => { expect(submitButtonInForm.type).toBe('submit'); }); - it('should add or remove button event listeners when readonly updates', async () => { + it('should add or remove button event listeners when readOnly updates', async () => { await elementIsStable(submitButtonInForm); - expect(submitButtonInForm.readonly).toBe(undefined); + expect(submitButtonInForm.readOnly).toBe(false); vi.spyOn(submitButtonInForm, 'removeEventListener'); - submitButtonInForm.readonly = true; + submitButtonInForm.readOnly = true; await elementIsStable(submitButtonInForm); expect(submitButtonInForm.removeEventListener).toBeCalledTimes(3); // 2x button controller, 1x command controller vi.spyOn(submitButtonInForm, 'addEventListener'); - submitButtonInForm.readonly = false; + submitButtonInForm.readOnly = false; await elementIsStable(submitButtonInForm); expect(submitButtonInForm.addEventListener).toBeCalledTimes(3); // 2x button controller, 1x command controller }); diff --git a/projects/core/src/internal/base/button.ts b/projects/core/src/internal/base/button.ts index 0749799f42..cc7e3aa4fc 100644 --- a/projects/core/src/internal/base/button.ts +++ b/projects/core/src/internal/base/button.ts @@ -53,7 +53,18 @@ export class BaseButton extends LitElement { * Like input readonly, sets a button semantically as visual treatment only * https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/readonly */ - @property({ type: Boolean, reflect: true }) readonly: boolean; + @property({ type: Boolean, attribute: 'readonly', reflect: true }) readOnly = false; + + /** + * @deprecated Use `readOnly`. The `readonly` attribute remains supported. + */ + get readonly(): boolean { + return this.readOnly; + } + + set readonly(value: boolean) { + this.readOnly = value; // eslint-disable-line local/stateless-property + } #form: string | HTMLFormElement | null = null; diff --git a/projects/core/src/internal/controllers/state-current.controller.test.ts b/projects/core/src/internal/controllers/state-current.controller.test.ts index f3cd34c8c2..9131e6cdb2 100644 --- a/projects/core/src/internal/controllers/state-current.controller.test.ts +++ b/projects/core/src/internal/controllers/state-current.controller.test.ts @@ -12,7 +12,7 @@ import { stateCurrent } from '@nvidia-elements/core/internal'; @customElement('state-current-controller-test-element') class StateCurrentControllerTestElement extends LitElement { @property({ type: String }) current: 'page' | 'step'; - @property({ type: Boolean }) readonly: boolean; + @property({ type: Boolean, attribute: 'readonly' }) readOnly = false; declare _internals: ElementInternals; render() { @@ -70,7 +70,7 @@ describe('state-current.controller', () => { expect(element._internals.ariaCurrent).toBe('page'); expect(element.matches(':state(current)')).toBe(true); - element.readonly = true; + element.readOnly = true; await elementIsStable(element); expect(element._internals.ariaCurrent).toBe(null); expect(element.matches(':state(current)')).toBe(false); @@ -107,7 +107,7 @@ describe('state-current.controller', () => { a.href = '#'; element.appendChild(a); element.current = 'page'; - element.readonly = true; + element.readOnly = true; element._internals.states.add('anchor'); element.requestUpdate(); await elementIsStable(element); @@ -119,12 +119,12 @@ describe('state-current.controller', () => { it('should restore current state when readonly is removed', async () => { element.current = 'page'; - element.readonly = true; + element.readOnly = true; await elementIsStable(element); expect(element._internals.ariaCurrent).toBe(null); expect(element.matches(':state(current)')).toBe(false); - element.readonly = 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 index e1db4bbcb0..9bc59069b0 100644 --- a/projects/core/src/internal/controllers/state-current.controller.ts +++ b/projects/core/src/internal/controllers/state-current.controller.ts @@ -14,7 +14,7 @@ export function stateCurrent(): ClassDecorator { target.addInitializer!((instance: T) => new StateCurrentController(instance)); } -type Current = ReactiveElement & { current: 'page' | 'step'; readonly?: boolean; _internals?: ElementInternals }; +type Current = ReactiveElement & { current: 'page' | 'step'; readOnly?: boolean; _internals?: ElementInternals }; export class StateCurrentController implements ReactiveController { constructor(private host: T) { @@ -26,7 +26,7 @@ export class StateCurrentController implements ReactiveContro } hostUpdated() { - if (this.host.readonly) { + if (this.host.readOnly) { this.host._internals!.ariaCurrent = null; this.host._internals!.states.delete('current'); return; diff --git a/projects/core/src/internal/controllers/state-disabled.controller.test.ts b/projects/core/src/internal/controllers/state-disabled.controller.test.ts index c9d1f7fcbe..351c1b6006 100644 --- a/projects/core/src/internal/controllers/state-disabled.controller.test.ts +++ b/projects/core/src/internal/controllers/state-disabled.controller.test.ts @@ -12,7 +12,7 @@ import { stateDisabled } from '@nvidia-elements/core/internal'; @customElement('state-disabled-controller-test-element') class StateDisabledControllerTestElement extends LitElement { @property({ type: Boolean }) disabled = false; - @property({ type: Boolean }) readonly = false; + @property({ type: Boolean, attribute: 'readonly' }) readOnly = false; declare _internals: ElementInternals; } @@ -54,7 +54,7 @@ describe('state-disabled.controller', () => { }); it('should remove aria-disabled if readonly', async () => { - element.readonly = true; + element.readOnly = true; 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 index ead88b4096..15663bbb68 100644 --- a/projects/core/src/internal/controllers/state-disabled.controller.ts +++ b/projects/core/src/internal/controllers/state-disabled.controller.ts @@ -15,7 +15,7 @@ export function stateDisabled(): ClassDecorator { target.addInitializer!((instance: T) => new StateDisabledController(instance)); } -export type Disabled = ReactiveElement & { disabled: boolean; readonly?: boolean; _internals?: ElementInternals }; +export type Disabled = ReactiveElement & { disabled: boolean; readOnly?: boolean; _internals?: ElementInternals }; export class StateDisabledController implements ReactiveController { constructor(private host: T) { @@ -39,7 +39,7 @@ export class StateDisabledController implements ReactiveCont this.host._internals!.states.delete('disabled'); } - if (this.host.readonly) { + if (this.host.readOnly) { this.host._internals!.ariaDisabled = null; } } diff --git a/projects/core/src/internal/controllers/state-expanded.controller.test.ts b/projects/core/src/internal/controllers/state-expanded.controller.test.ts index f76ef8ccef..dba1218f5c 100644 --- a/projects/core/src/internal/controllers/state-expanded.controller.test.ts +++ b/projects/core/src/internal/controllers/state-expanded.controller.test.ts @@ -12,7 +12,7 @@ import { stateExpanded } from '@nvidia-elements/core/internal'; @customElement('state-expanded-controller-test-element') class StateExpandedControllerTestElement extends LitElement { @property({ type: Boolean }) expanded: boolean; - @property({ type: Boolean }) readonly: boolean; + @property({ type: Boolean, attribute: 'readonly' }) readOnly = false; declare _internals: ElementInternals; } @@ -66,7 +66,7 @@ describe('state-expanded.controller', () => { expect(element._internals.ariaExpanded).toBe('true'); expect(element.matches(':state(expanded)')).toBe(true); - element.readonly = true; + element.readOnly = true; await elementIsStable(element); expect(element._internals.ariaExpanded).toBe(null); expect(element.matches(':state(expanded)')).toBe(false); diff --git a/projects/core/src/internal/controllers/state-expanded.controller.ts b/projects/core/src/internal/controllers/state-expanded.controller.ts index c06a60a9a3..237eb8adba 100644 --- a/projects/core/src/internal/controllers/state-expanded.controller.ts +++ b/projects/core/src/internal/controllers/state-expanded.controller.ts @@ -15,7 +15,7 @@ export function stateExpanded(): ClassDecorator { target.addInitializer!((instance: T) => new StateExpandedController(instance)); } -export type Expanded = ReactiveElement & { expanded: boolean; readonly?: boolean; _internals?: ElementInternals }; +export type Expanded = ReactiveElement & { expanded: boolean; readOnly?: boolean; _internals?: ElementInternals }; export class StateExpandedController implements ReactiveController { constructor(private host: T) { @@ -37,7 +37,7 @@ export class StateExpandedController implements ReactiveCont this.host._internals!.states.delete('expanded'); } - if (this.host.readonly) { + if (this.host.readOnly) { this.host._internals!.ariaExpanded = null; this.host._internals!.states.delete('expanded'); } diff --git a/projects/core/src/internal/controllers/state-pressed.controller.test.ts b/projects/core/src/internal/controllers/state-pressed.controller.test.ts index aab23d39fc..908ddfb2b4 100644 --- a/projects/core/src/internal/controllers/state-pressed.controller.test.ts +++ b/projects/core/src/internal/controllers/state-pressed.controller.test.ts @@ -12,7 +12,7 @@ import { statePressed } from '@nvidia-elements/core/internal'; @customElement('state-pressed-controller-test-element') class StatePressedControllerTestElement extends LitElement { @property({ type: Boolean }) pressed: boolean; - @property({ type: Boolean }) readonly: boolean; + @property({ type: Boolean, attribute: 'readonly' }) readOnly = false; declare _internals: ElementInternals; } @@ -60,7 +60,7 @@ describe('state-pressed.controller', () => { expect(element._internals.ariaPressed).toBe('true'); expect(element.matches(':state(pressed)')).toBe(true); - element.readonly = 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 index ce53265dfa..d6d5efd814 100644 --- a/projects/core/src/internal/controllers/state-pressed.controller.ts +++ b/projects/core/src/internal/controllers/state-pressed.controller.ts @@ -15,7 +15,7 @@ export function statePressed(): ClassDecorator { target.addInitializer!((instance: T) => new StatePressedController(instance)); } -export type Pressed = ReactiveElement & { pressed: boolean; readonly?: boolean; _internals?: ElementInternals }; +export type Pressed = ReactiveElement & { pressed: boolean; readOnly?: boolean; _internals?: ElementInternals }; export class StatePressedController implements ReactiveController { constructor(private host: T) { @@ -37,7 +37,7 @@ export class StatePressedController implements ReactiveContro this.host._internals!.states.delete('pressed'); } - if (this.host.readonly) { + if (this.host.readOnly) { this.host._internals!.ariaPressed = null; this.host._internals!.states.delete('pressed'); } diff --git a/projects/core/src/internal/controllers/state-selected.controller.test.ts b/projects/core/src/internal/controllers/state-selected.controller.test.ts index f893384dd5..0887ad8200 100644 --- a/projects/core/src/internal/controllers/state-selected.controller.test.ts +++ b/projects/core/src/internal/controllers/state-selected.controller.test.ts @@ -12,7 +12,7 @@ import { stateSelected } from '@nvidia-elements/core/internal'; @customElement('state-selected-controller-test-element') class StateSelectedControllerTestElement extends LitElement { @property({ type: Boolean }) selected: boolean; - @property({ type: Boolean }) readonly: boolean; + @property({ type: Boolean, attribute: 'readonly' }) readOnly = false; declare _internals: ElementInternals; render() { @@ -70,7 +70,7 @@ describe('state-selected.controller', () => { expect(element._internals.ariaSelected).toBe('true'); expect(element.matches(':state(selected)')).toBe(true); - element.readonly = true; + element.readOnly = true; await elementIsStable(element); expect(element._internals.ariaSelected).toBe(null); expect(element.matches(':state(selected)')).toBe(false); @@ -107,7 +107,7 @@ describe('state-selected.controller', () => { a.href = '#'; element.appendChild(a); element.selected = true; - element.readonly = true; + element.readOnly = true; element._internals.states.add('anchor'); element.requestUpdate(); await elementIsStable(element); @@ -119,12 +119,12 @@ describe('state-selected.controller', () => { it('should restore selected state when readonly is removed', async () => { element.selected = true; - element.readonly = true; + element.readOnly = true; await elementIsStable(element); expect(element._internals.ariaSelected).toBe(null); expect(element.matches(':state(selected)')).toBe(false); - element.readonly = false; + element.readOnly = false; await elementIsStable(element); expect(element._internals.ariaSelected).toBe('true'); expect(element.matches(':state(selected)')).toBe(true); diff --git a/projects/core/src/internal/controllers/state-selected.controller.ts b/projects/core/src/internal/controllers/state-selected.controller.ts index f5017ec703..f555483a8a 100644 --- a/projects/core/src/internal/controllers/state-selected.controller.ts +++ b/projects/core/src/internal/controllers/state-selected.controller.ts @@ -15,7 +15,7 @@ export function stateSelected(): ClassDecorator { target.addInitializer!((instance: T) => new StateSelectedController(instance)); } -export type Selected = ReactiveElement & { selected: boolean; readonly?: boolean; _internals?: ElementInternals }; +export type Selected = ReactiveElement & { selected: boolean; readOnly?: boolean; _internals?: ElementInternals }; export class StateSelectedController implements ReactiveController { constructor(private host: T) { @@ -27,7 +27,7 @@ export class StateSelectedController implements ReactiveCont } hostUpdated() { - if (this.host.readonly) { + if (this.host.readOnly) { this.host._internals!.ariaSelected = null; this.host._internals!.states.delete('selected'); return; diff --git a/projects/core/src/internal/controllers/type-anchor.controller.test.ts b/projects/core/src/internal/controllers/type-anchor.controller.test.ts index c53a50f275..194ed1329b 100644 --- a/projects/core/src/internal/controllers/type-anchor.controller.test.ts +++ b/projects/core/src/internal/controllers/type-anchor.controller.test.ts @@ -12,7 +12,7 @@ import { createFixture, removeFixture, emulateClick, elementIsStable } from '@in @customElement('type-anchor-test-element') class TypeAnchorTestElement extends LitElement { @property({ type: Boolean }) disabled = false; - @property({ type: Boolean }) readonly = false; + @property({ type: Boolean, attribute: 'readonly' }) readOnly = false; declare _internals: ElementInternals; @@ -51,7 +51,7 @@ describe('type-anchor.controller', () => { element.disabled = true; emulateClick(anchor); - expect(element.readonly).toBe(true); + expect(element.readOnly).toBe(true); expect(clicks).toBe(1); element.disabled = false; @@ -66,7 +66,7 @@ describe('type-anchor.controller', () => { emulateClick(anchor); expect(clicks).toBe(1); - expect(element.readonly).toBe(true); + expect(element.readOnly).toBe(true); expect(anchor.style.textDecoration).toBe(''); expect(element.style.cursor).toBe(''); expect(element.matches(':state(anchor)')).toBe(true); @@ -102,7 +102,7 @@ describe('type-anchor.controller wrapped element', () => { element.disabled = true; emulateClick(anchor); - expect(element.readonly).toBe(true); + expect(element.readOnly).toBe(true); expect(clicks).toBe(1); element.disabled = false; @@ -117,7 +117,7 @@ describe('type-anchor.controller wrapped element', () => { emulateClick(anchor); expect(clicks).toBe(1); - expect(element.readonly).toBe(true); + expect(element.readOnly).toBe(true); expect(anchor.style.textDecoration).toBe('none'); expect(element.style.cursor).toBe('pointer'); expect(element.matches(':state(anchor)')).toBe(true); diff --git a/projects/core/src/internal/controllers/type-anchor.controller.ts b/projects/core/src/internal/controllers/type-anchor.controller.ts index 52416eae53..b69f3685df 100644 --- a/projects/core/src/internal/controllers/type-anchor.controller.ts +++ b/projects/core/src/internal/controllers/type-anchor.controller.ts @@ -15,7 +15,7 @@ export function typeAnchor(): ClassDecorator { export interface Anchor extends ReactiveElement { disabled: boolean; - readonly: boolean; + readOnly: boolean; _internals: ElementInternals; } @@ -55,7 +55,7 @@ export class TypeAnchorController implements ReactiveControlle this.#updateAnchorSlotAssignment(); if (this.#anchor) { - this.host.readonly = true; + this.host.readOnly = true; this.host._internals?.states.add('anchor'); } else { this.host._internals?.states.delete('anchor'); diff --git a/projects/core/src/internal/controllers/type-button.controller.test.ts b/projects/core/src/internal/controllers/type-button.controller.test.ts index 2ada4cb485..7c105cdfd0 100644 --- a/projects/core/src/internal/controllers/type-button.controller.test.ts +++ b/projects/core/src/internal/controllers/type-button.controller.test.ts @@ -11,7 +11,7 @@ import { createFixture, removeFixture, elementIsStable } from '@internals/testin @typeButton() @customElement('type-button-controller-test-element') class TypeButtonControllerTestElement extends LitElement { - @property({ type: Boolean }) readonly = false; + @property({ type: Boolean, attribute: 'readonly' }) readOnly = false; @property({ type: Boolean }) disabled = false; @property({ type: String }) href: string; _internals: ElementInternals; @@ -47,7 +47,7 @@ describe('TypeButtonController', () => { }); it('should remove tabindex and role if readonly', async () => { - element.readonly = true; + element.readOnly = true; await elementIsStable(element); expect(element.tabIndex).toBe(-1); expect(element._internals.role).toBe('none'); diff --git a/projects/core/src/internal/controllers/type-button.controller.ts b/projects/core/src/internal/controllers/type-button.controller.ts index ac6839eeba..7efadd10d5 100644 --- a/projects/core/src/internal/controllers/type-button.controller.ts +++ b/projects/core/src/internal/controllers/type-button.controller.ts @@ -14,7 +14,7 @@ export function typeButton(): ClassDecorator { } export interface Button extends ReactiveElement { - readonly: boolean; + readOnly: boolean; disabled: boolean; _internals?: ElementInternals; } @@ -43,7 +43,7 @@ export class TypeButtonController implements ReactiveControlle this.host.tabIndex = this.host.disabled ? -1 : this.#initialTabIndex; - if (this.host.readonly) { + 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 index 0cd2ee77c5..88ed610d9d 100644 --- a/projects/core/src/internal/controllers/type-command.controller.test.ts +++ b/projects/core/src/internal/controllers/type-command.controller.test.ts @@ -13,7 +13,7 @@ class TypeCommandControllerTestElement extends LitElement { @property({ type: String }) command: string; @property({ type: String, attribute: 'commandfor' }) commandfor: string; @property({ type: Object }) commandForElement: HTMLElement; - @property({ type: Boolean }) readonly: boolean; + @property({ type: Boolean, attribute: 'readonly' }) readOnly = false; @property({ type: Boolean }) disabled: boolean; _typeCommandController = new TypeCommandController(this); } diff --git a/projects/core/src/internal/controllers/type-command.controller.ts b/projects/core/src/internal/controllers/type-command.controller.ts index ec297df20d..cad37a04ae 100644 --- a/projects/core/src/internal/controllers/type-command.controller.ts +++ b/projects/core/src/internal/controllers/type-command.controller.ts @@ -19,7 +19,7 @@ export type Command = ReactiveElement & command: string; commandfor: string | null; commandForElement: HTMLElement | null; - readonly: boolean; + readOnly: boolean; disabled: boolean; }; @@ -38,7 +38,7 @@ export class TypeCommandController implements ReactiveControl } #updateListener() { - if (!this.host.readonly && !this.host.disabled) { + if (!this.host.readOnly && !this.host.disabled) { this.host.addEventListener('click', this.#triggerCommand); } else { this.host.removeEventListener('click', this.#triggerCommand); diff --git a/projects/core/src/internal/controllers/type-interest.controller.test.ts b/projects/core/src/internal/controllers/type-interest.controller.test.ts index b165e83314..b6848abf19 100644 --- a/projects/core/src/internal/controllers/type-interest.controller.test.ts +++ b/projects/core/src/internal/controllers/type-interest.controller.test.ts @@ -12,7 +12,7 @@ import { TypeInterestController, type InterestEvent } from '@nvidia-elements/cor class TypeInterestControllerTestElement extends LitElement { @property({ type: String, reflect: true }) interestfor: string; @property({ type: Object }) interestForElement: HTMLElement; - @property({ type: Boolean }) readonly: boolean; + @property({ type: Boolean, attribute: 'readonly' }) readOnly = false; @property({ type: Boolean }) disabled: boolean; #typeInterestController = new TypeInterestController(this); } diff --git a/projects/core/src/internal/controllers/type-interest.controller.ts b/projects/core/src/internal/controllers/type-interest.controller.ts index 253712f3f5..14af129dbe 100644 --- a/projects/core/src/internal/controllers/type-interest.controller.ts +++ b/projects/core/src/internal/controllers/type-interest.controller.ts @@ -22,7 +22,7 @@ export type Interest = ReactiveElement & HTMLElement & { interestfor: string | null; interestForElement: HTMLElement | null; - readonly: boolean; + readOnly: boolean; disabled: boolean; }; diff --git a/projects/core/src/internal/controllers/type-submit.controller.test.ts b/projects/core/src/internal/controllers/type-submit.controller.test.ts index 518d1f7f51..91b03e2391 100644 --- a/projects/core/src/internal/controllers/type-submit.controller.test.ts +++ b/projects/core/src/internal/controllers/type-submit.controller.test.ts @@ -15,7 +15,7 @@ class TypeSubmitControllerTestElement extends LitElement { @property({ type: String }) value: string; @property({ type: Boolean }) disabled: boolean; @property({ type: String }) type: 'button' | 'submit' | 'reset'; - @property({ type: Boolean }) readonly: boolean; + @property({ type: Boolean, attribute: 'readonly' }) readOnly = false; #form: HTMLFormElement; @@ -93,17 +93,17 @@ describe('type-submit.controller', () => { expect((await event).target).toBe(button); }); - it('should add or remove button event listeners when readonly updates', async () => { + it('should add or remove button event listeners when readOnly updates', async () => { await elementIsStable(submitButtonInForm); - expect(submitButtonInForm.readonly).toBe(undefined); + expect(submitButtonInForm.readOnly).toBe(false); vi.spyOn(submitButtonInForm, 'removeEventListener'); - submitButtonInForm.readonly = true; + submitButtonInForm.readOnly = true; await elementIsStable(submitButtonInForm); expect(submitButtonInForm.removeEventListener).toBeCalledTimes(2); vi.spyOn(submitButtonInForm, 'addEventListener'); - submitButtonInForm.readonly = false; + submitButtonInForm.readOnly = false; await elementIsStable(submitButtonInForm); expect(submitButtonInForm.addEventListener).toBeCalledTimes(2); }); diff --git a/projects/core/src/internal/controllers/type-submit.controller.ts b/projects/core/src/internal/controllers/type-submit.controller.ts index 549d675fa1..ba0bd85fa7 100644 --- a/projects/core/src/internal/controllers/type-submit.controller.ts +++ b/projects/core/src/internal/controllers/type-submit.controller.ts @@ -20,7 +20,7 @@ export type Submit = ReactiveElement & value: string; disabled: boolean; type: 'button' | 'submit' | 'reset'; - readonly: boolean; + readOnly: boolean; form?: HTMLFormElement | null; _internals: ElementInternals; }; @@ -48,7 +48,7 @@ export class TypeSubmitController implements ReactiveControlle #setupNativeButtonBehavior() { this.#removeNativeButtonBehavior(); - if (!this.host.readonly && !this.host.disabled) { + if (!this.host.readOnly && !this.host.disabled) { this.host.addEventListener('click', this.#triggerNativeButtonBehavior); this.host.addEventListener('keyup', this.#emulateKeyBoardEventBehavior); } diff --git a/projects/core/src/internal/types/index.ts b/projects/core/src/internal/types/index.ts index 3f6633c2b5..970f573af7 100644 --- a/projects/core/src/internal/types/index.ts +++ b/projects/core/src/internal/types/index.ts @@ -199,6 +199,11 @@ export interface NveElement { * - `true` - The element has a readonly state: the user cannot change its value, but can still focus and copy it. * - `false` - The element allows editing and the user can change its value through interaction. */ + readOnly?: boolean; + + /** + * @deprecated Use `readOnly`. The `readonly` attribute remains supported. + */ readonly?: boolean; /** Defines the value associated with the element's name when submitting the form data. The server receives this value in params when the form submits through this button. [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#value) */ diff --git a/projects/internals/eslint/src/local/reserved-property-names.js b/projects/internals/eslint/src/local/reserved-property-names.js index 016ce6f511..7896f00387 100644 --- a/projects/internals/eslint/src/local/reserved-property-names.js +++ b/projects/internals/eslint/src/local/reserved-property-names.js @@ -18,6 +18,13 @@ export default { if (decoratorName === 'property' || decoratorName === 'state' || decoratorName === 'event') { const propName = node.parent.key.name; + if (propName === 'readonly') { + context.report({ + node: node.parent.key, + message: `"@${decoratorName} readonly" must use the native DOM property name \`readOnly\` with \`attribute: 'readonly'\`.` + }); + } + if (isReservedProperty(propName)) { context.report({ node: node.parent.key, diff --git a/projects/internals/eslint/src/local/reserved-property-names.test.js b/projects/internals/eslint/src/local/reserved-property-names.test.js new file mode 100644 index 0000000000..d88cb1b9ba --- /dev/null +++ b/projects/internals/eslint/src/local/reserved-property-names.test.js @@ -0,0 +1,63 @@ +import { beforeEach, test } from 'node:test'; +import { RuleTester } from 'eslint'; +import tseslint from 'typescript-eslint'; +import reservedPropertyNames from './reserved-property-names.js'; + +let tester; + +beforeEach(() => { + tester = new RuleTester({ + languageOptions: { + parser: tseslint.parser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module' + } + } + }); +}); + +test('valid: readOnly property uses readonly attribute', () => { + tester.run('reserved-property-names', reservedPropertyNames, { + valid: [ + { + code: ` + class TestElement { + @property({ type: Boolean, attribute: 'readonly' }) readOnly = false; + } + ` + }, + { + code: ` + class TestElement { + get readonly() { + return this.readOnly; + } + } + ` + } + ], + invalid: [] + }); +}); + +test('invalid: decorated readonly property', () => { + tester.run('reserved-property-names', reservedPropertyNames, { + valid: [], + invalid: [ + { + code: ` + class TestElement { + @property({ type: Boolean }) readonly = false; + } + `, + errors: [ + { + message: + '"@property readonly" must use the native DOM property name `readOnly` with `attribute: \'readonly\'`.' + } + ] + } + ] + }); +}); diff --git a/projects/site/src/docs/elements/tag.md b/projects/site/src/docs/elements/tag.md index f975806d40..365efa033a 100644 --- a/projects/site/src/docs/elements/tag.md +++ b/projects/site/src/docs/elements/tag.md @@ -30,7 +30,7 @@ ## Readonly -{% api 'nve-tag', 'property', 'readonly' %} +{% api 'nve-tag', 'property', 'readOnly' %} {% example '@nvidia-elements/core/tag/tag.examples.json' 'Readonly' %}