diff --git a/.changeset/small-pens-applaud.md b/.changeset/small-pens-applaud.md new file mode 100644 index 000000000..ca5391579 --- /dev/null +++ b/.changeset/small-pens-applaud.md @@ -0,0 +1,10 @@ +--- +'@lion/fieldset': patch +'@lion/form-core': patch +'@lion/input': patch +'@lion/select': patch +'@lion/switch': patch +'@lion/textarea': patch +--- + +Refactor of some fields to ensure that \_inputNode has the right type. It starts as HTMLElement for LionField, and all HTMLInputElement, HTMLSelectElement and HTMLTextAreaElement logic, are moved to the right places. diff --git a/packages/fieldset/test/lion-fieldset.test.js b/packages/fieldset/test/lion-fieldset.test.js index f40f86092..5aca41920 100644 --- a/packages/fieldset/test/lion-fieldset.test.js +++ b/packages/fieldset/test/lion-fieldset.test.js @@ -1,4 +1,6 @@ import { runFormGroupMixinSuite } from '@lion/form-core/test-suites/form-group/FormGroupMixin.suite.js'; +import { runFormGroupMixinInputSuite } from '@lion/form-core/test-suites/form-group/FormGroupMixin-input.suite.js'; import '../lion-fieldset.js'; runFormGroupMixinSuite({ tagString: 'lion-fieldset' }); +runFormGroupMixinInputSuite({ tagString: 'lion-fieldset' }); diff --git a/packages/form-core/src/LionField.js b/packages/form-core/src/LionField.js index b25330ee7..185df721e 100644 --- a/packages/form-core/src/LionField.js +++ b/packages/form-core/src/LionField.js @@ -39,56 +39,7 @@ export class LionField extends FormControlMixin( } get _inputNode() { - return /** @type {HTMLInputElement} */ (super._inputNode); // casts type - } - - /** @type {number} */ - get selectionStart() { - const native = this._inputNode; - if (native && native.selectionStart) { - return native.selectionStart; - } - return 0; - } - - set selectionStart(value) { - const native = this._inputNode; - if (native && native.selectionStart) { - native.selectionStart = value; - } - } - - /** @type {number} */ - get selectionEnd() { - const native = this._inputNode; - if (native && native.selectionEnd) { - return native.selectionEnd; - } - return 0; - } - - set selectionEnd(value) { - const native = this._inputNode; - if (native && native.selectionEnd) { - native.selectionEnd = value; - } - } - - // We don't delegate, because we want to preserve caret position via _setValueAndPreserveCaret - /** @type {string} */ - set value(value) { - // if not yet connected to dom can't change the value - if (this._inputNode) { - this._setValueAndPreserveCaret(value); - /** @type {string | undefined} */ - this.__value = undefined; - } else { - this.__value = value; - } - } - - get value() { - return (this._inputNode && this._inputNode.value) || this.__value || ''; + return /** @type {HTMLElement} */ (super._inputNode); // casts type } constructor() { @@ -119,26 +70,6 @@ export class LionField extends FormControlMixin( this._inputNode.removeEventListener('change', this._onChange); } - /** - * @param {import('lit-element').PropertyValues } changedProperties - */ - updated(changedProperties) { - super.updated(changedProperties); - - if (changedProperties.has('disabled')) { - this._inputNode.disabled = this.disabled; - this.validate(); - } - - if (changedProperties.has('name')) { - this._inputNode.name = this.name; - } - - if (changedProperties.has('autocomplete')) { - this._inputNode.autocomplete = /** @type {string} */ (this.autocomplete); - } - } - resetInteractionState() { super.resetInteractionState(); this.submitted = false; @@ -164,30 +95,4 @@ export class LionField extends FormControlMixin( }), ); } - - /** - * Restores the cursor to its original position after updating the value. - * @param {string} newValue The value that should be saved. - */ - _setValueAndPreserveCaret(newValue) { - // Only preserve caret if focused (changing selectionStart will move focus in Safari) - if (this.focused) { - // Not all elements might have selection, and even if they have the - // right properties, accessing them might throw an exception (like for - // ) - try { - const start = this._inputNode.selectionStart; - this._inputNode.value = newValue; - // The cursor automatically jumps to the end after re-setting the value, - // so restore it to its original position. - this._inputNode.selectionStart = start; - this._inputNode.selectionEnd = start; - } catch (error) { - // Just set the value and give up on the caret. - this._inputNode.value = newValue; - } - } else { - this._inputNode.value = newValue; - } - } } diff --git a/packages/form-core/src/ValueMixin.js b/packages/form-core/src/ValueMixin.js new file mode 100644 index 000000000..cbd226171 --- /dev/null +++ b/packages/form-core/src/ValueMixin.js @@ -0,0 +1,57 @@ +import { dedupeMixin } from '@lion/core'; + +/** + * @typedef {import('../types/ValueMixinTypes').ValueMixin} ValueMixin + * @type {ValueMixin} + * @param {import('@open-wc/dedupe-mixin').Constructor} superclass} superclass + */ +const ValueMixinImplementation = superclass => + class ValueMixin extends superclass { + get value() { + return (this._inputNode && this._inputNode.value) || this.__value || ''; + } + + // We don't delegate, because we want to preserve caret position via _setValueAndPreserveCaret + /** @type {string} */ + set value(value) { + // if not yet connected to dom can't change the value + if (this._inputNode) { + this._setValueAndPreserveCaret(value); + /** @type {string | undefined} */ + this.__value = undefined; + } else { + this.__value = value; + } + } + + /** + * Restores the cursor to its original position after updating the value. + * @param {string} newValue The value that should be saved. + */ + _setValueAndPreserveCaret(newValue) { + // Only preserve caret if focused (changing selectionStart will move focus in Safari) + if (this.focused) { + // Not all elements might have selection, and even if they have the + // right properties, accessing them might throw an exception (like for + // ) + try { + // SelectElement doesn't have selectionStart/selectionEnd + if (!(this._inputNode instanceof HTMLSelectElement)) { + const start = this._inputNode.selectionStart; + this._inputNode.value = newValue; + // The cursor automatically jumps to the end after re-setting the value, + // so restore it to its original position. + this._inputNode.selectionStart = start; + this._inputNode.selectionEnd = start; + } + } catch (error) { + // Just set the value and give up on the caret. + this._inputNode.value = newValue; + } + } else { + this._inputNode.value = newValue; + } + } + }; + +export const ValueMixin = dedupeMixin(ValueMixinImplementation); diff --git a/packages/form-core/test-suites/form-group/FormGroupMixin-input.suite.js b/packages/form-core/test-suites/form-group/FormGroupMixin-input.suite.js new file mode 100644 index 000000000..13e91c87d --- /dev/null +++ b/packages/form-core/test-suites/form-group/FormGroupMixin-input.suite.js @@ -0,0 +1,197 @@ +import { LitElement } from '@lion/core'; +import { localizeTearDown } from '@lion/localize/test-helpers.js'; +import { defineCE, expect, html, unsafeStatic, fixture } from '@open-wc/testing'; +import { LionInput } from '@lion/input'; +import '@lion/form-core/lion-field.js'; +import { FormGroupMixin } from '../../src/form-group/FormGroupMixin.js'; + +/** + * @param {{ tagString?: string, childTagString?:string }} [cfg] + */ +export function runFormGroupMixinInputSuite(cfg = {}) { + const FormChild = class extends LionInput { + get slots() { + return { + ...super.slots, + input: () => document.createElement('input'), + }; + } + }; + + const childTagString = cfg.childTagString || defineCE(FormChild); + + // @ts-expect-error + const FormGroup = class extends FormGroupMixin(LitElement) { + constructor() { + super(); + /** @override from FormRegistrarMixin */ + this._isFormOrFieldset = true; + this._repropagationRole = 'fieldset'; // configures FormControlMixin + } + }; + + const tagString = cfg.tagString || defineCE(FormGroup); + const tag = unsafeStatic(tagString); + const childTag = unsafeStatic(childTagString); + + beforeEach(() => { + localizeTearDown(); + }); + + describe('FormGroupMixin with LionInput', () => { + it('serializes undefined values as "" (nb radios/checkboxes are always serialized)', async () => { + const fieldset = /** @type {FormGroup} */ (await fixture(html` + <${tag}> + <${childTag} name="custom[]"> + <${childTag} name="custom[]"> + + `)); + console.log(fieldset.formElements['custom[]'][1]); + fieldset.formElements['custom[]'][0].modelValue = 'custom 1'; + fieldset.formElements['custom[]'][1].modelValue = undefined; + + expect(fieldset.serializedValue).to.deep.equal({ + 'custom[]': ['custom 1', ''], + }); + }); + }); + + describe('Screen reader relations (aria-describedby) for child inputs and fieldsets', () => { + /** @type {Function} */ + let childAriaFixture; + /** @type {Function} */ + let childAriaTest; + + before(() => { + // Legend: + // - l1 means level 1 (outer) fieldset + // - l2 means level 2 (inner) fieldset + // - g means group: the help-text or feedback belongs to group + // - f means field(lion-input in fixture below): the help-text or feedback belongs to field + // - 'a' or 'b' behind 'f' indicate which field in a fieldset is meant (a: first, b: second) + + childAriaFixture = async ( + msgSlotType = 'feedback', // eslint-disable-line no-shadow + ) => { + const dom = /** @type {FormGroup} */ (await fixture(html` + <${tag} name="l1_g"> + <${childTag} name="l1_fa"> +
+ + + + <${childTag} name="l1_fb"> +
+ + + + + + <${tag} name="l2_g"> + <${childTag} name="l2_fa"> +
+ + + + <${childTag} name="l2_fb"> +
+ + + +
+ + + + + +
+ + + `)); + return dom; + }; + + // eslint-disable-next-line no-shadow + childAriaTest = (/** @type {FormGroup} */ childAriaFixture) => { + /* eslint-disable camelcase */ + // Message elements: all elements pointed at by inputs + const msg_l1_g = /** @type {FormGroup} */ (childAriaFixture.querySelector('#msg_l1_g')); + const msg_l1_fa = /** @type {FormChild} */ (childAriaFixture.querySelector('#msg_l1_fa')); + const msg_l1_fb = /** @type {FormChild} */ (childAriaFixture.querySelector('#msg_l1_fb')); + const msg_l2_g = /** @type {FormGroup} */ (childAriaFixture.querySelector('#msg_l2_g')); + const msg_l2_fa = /** @type {FormChild} */ (childAriaFixture.querySelector('#msg_l2_fa')); + const msg_l2_fb = /** @type {FormChild} */ (childAriaFixture.querySelector('#msg_l2_fb')); + + // Field elements: all inputs pointing to message elements + const input_l1_fa = /** @type {FormChild} */ (childAriaFixture.querySelector( + 'input[name=l1_fa]', + )); + const input_l1_fb = /** @type {FormChild} */ (childAriaFixture.querySelector( + 'input[name=l1_fb]', + )); + const input_l2_fa = /** @type {FormChild} */ (childAriaFixture.querySelector( + 'input[name=l2_fa]', + )); + const input_l2_fb = /** @type {FormChild} */ (childAriaFixture.querySelector( + 'input[name=l2_fb]', + )); + + /* eslint-enable camelcase */ + + // 'L1' fields (inside lion-fieldset[name="l1_g"]) should point to l1(group) msg + expect(input_l1_fa.getAttribute('aria-describedby')).to.contain( + msg_l1_g.id, + 'l1 input(a) refers parent/group', + ); + expect(input_l1_fb.getAttribute('aria-describedby')).to.contain( + msg_l1_g.id, + 'l1 input(b) refers parent/group', + ); + + // Also check that aria-describedby of the inputs are not overridden (this relation was + // put there in lion-input(using lion-field)). + expect(input_l1_fa.getAttribute('aria-describedby')).to.contain( + msg_l1_fa.id, + 'l1 input(a) refers local field', + ); + expect(input_l1_fb.getAttribute('aria-describedby')).to.contain( + msg_l1_fb.id, + 'l1 input(b) refers local field', + ); + + // Also make feedback element point to nested fieldset inputs + expect(input_l2_fa.getAttribute('aria-describedby')).to.contain( + msg_l1_g.id, + 'l2 input(a) refers grandparent/group.group', + ); + expect(input_l2_fb.getAttribute('aria-describedby')).to.contain( + msg_l1_g.id, + 'l2 input(b) refers grandparent/group.group', + ); + + // Check order: the nearest ('dom wise': so 1. local, 2. parent, 3. grandparent) message + // should be read first by screen reader + const dA = /** @type {string} */ (input_l2_fa.getAttribute('aria-describedby')); + expect( + // @ts-expect-error + dA.indexOf(msg_l2_fa.id) < dA.indexOf(msg_l2_g.id) < dA.indexOf(msg_l1_g.id), + ).to.equal(true, 'order of ids'); + const dB = input_l2_fb.getAttribute('aria-describedby'); + expect( + // @ts-expect-error + dB.indexOf(msg_l2_fb.id) < dB.indexOf(msg_l2_g.id) < dB.indexOf(msg_l1_g.id), + ).to.equal(true, 'order of ids'); + }; + + it(`reads feedback message belonging to fieldset when child input is focused + (via aria-describedby)`, async () => { + childAriaTest(await childAriaFixture('feedback')); + }); + + it(`reads help-text message belonging to fieldset when child input is focused + (via aria-describedby)`, async () => { + childAriaTest(await childAriaFixture('help-text')); + }); + }); + }); +} diff --git a/packages/form-core/test-suites/form-group/FormGroupMixin.suite.js b/packages/form-core/test-suites/form-group/FormGroupMixin.suite.js index 19b7d4022..dbe8451cc 100644 --- a/packages/form-core/test-suites/form-group/FormGroupMixin.suite.js +++ b/packages/form-core/test-suites/form-group/FormGroupMixin.suite.js @@ -10,7 +10,7 @@ import { aTimeout, } from '@open-wc/testing'; import sinon from 'sinon'; -import { LionField, IsNumber, Validator } from '@lion/form-core'; +import { IsNumber, Validator, LionField } from '@lion/form-core'; import '@lion/form-core/lion-field.js'; import { FormGroupMixin } from '../../src/form-group/FormGroupMixin.js'; @@ -710,21 +710,6 @@ export function runFormGroupMixinSuite(cfg = {}) { expect(fieldset.serializedValue).to.deep.equal({ price: 0 }); }); - it('serializes undefined values as ""(nb radios/checkboxes are always serialized)', async () => { - const fieldset = /** @type {FormGroup} */ (await fixture(html` - <${tag}> - <${childTag} name="custom[]"> - <${childTag} name="custom[]"> - - `)); - fieldset.formElements['custom[]'][0].modelValue = 'custom 1'; - fieldset.formElements['custom[]'][1].modelValue = undefined; - - expect(fieldset.serializedValue).to.deep.equal({ - 'custom[]': ['custom 1', ''], - }); - }); - it('allows for nested fieldsets', async () => { const fieldset = /** @type {FormGroup} */ (await fixture(html` <${tag} name="userData"> @@ -1119,153 +1104,6 @@ export function runFormGroupMixinSuite(cfg = {}) { expect(el.hasAttribute('aria-labelledby')).to.equal(true); expect(el.getAttribute('aria-labelledby')).contains(label.id); }); - - describe('Screen reader relations (aria-describedby) for child fields and fieldsets', () => { - /** @type {Function} */ - let childAriaFixture; - /** @type {Function} */ - let childAriaTest; - - before(() => { - // Legend: - // - l1 means level 1 (outer) fieldset - // - l2 means level 2 (inner) fieldset - // - g means group: the help-text or feedback belongs to group - // - f means field(lion-input in fixture below): the help-text or feedback belongs to field - // - 'a' or 'b' behind 'f' indicate which field in a fieldset is meant (a: first, b: second) - - childAriaFixture = async ( - msgSlotType = 'feedback', // eslint-disable-line no-shadow - ) => { - const dom = /** @type {FormGroup} */ (await fixture(html` - <${tag} name="l1_g"> - <${childTag} name="l1_fa"> -
- - - - <${childTag} name="l1_fb"> -
- - - - - - <${tag} name="l2_g"> - <${childTag} name="l2_fa"> -
- - - - <${childTag} name="l2_fb"> -
- - - -
- - - - - -
- - - `)); - return dom; - }; - - // eslint-disable-next-line no-shadow - childAriaTest = (/** @type {FormGroup} */ childAriaFixture) => { - /* eslint-disable camelcase */ - // Message elements: all elements pointed at by inputs - const msg_l1_g = /** @type {FormGroup} */ (childAriaFixture.querySelector('#msg_l1_g')); - const msg_l1_fa = /** @type {FormChild} */ (childAriaFixture.querySelector( - '#msg_l1_fa', - )); - const msg_l1_fb = /** @type {FormChild} */ (childAriaFixture.querySelector( - '#msg_l1_fb', - )); - const msg_l2_g = /** @type {FormGroup} */ (childAriaFixture.querySelector('#msg_l2_g')); - const msg_l2_fa = /** @type {FormChild} */ (childAriaFixture.querySelector( - '#msg_l2_fa', - )); - const msg_l2_fb = /** @type {FormChild} */ (childAriaFixture.querySelector( - '#msg_l2_fb', - )); - - // Field elements: all inputs pointing to message elements - const input_l1_fa = /** @type {FormChild} */ (childAriaFixture.querySelector( - 'input[name=l1_fa]', - )); - const input_l1_fb = /** @type {FormChild} */ (childAriaFixture.querySelector( - 'input[name=l1_fb]', - )); - const input_l2_fa = /** @type {FormChild} */ (childAriaFixture.querySelector( - 'input[name=l2_fa]', - )); - const input_l2_fb = /** @type {FormChild} */ (childAriaFixture.querySelector( - 'input[name=l2_fb]', - )); - - /* eslint-enable camelcase */ - - // 'L1' fields (inside lion-fieldset[name="l1_g"]) should point to l1(group) msg - expect(input_l1_fa.getAttribute('aria-describedby')).to.contain( - msg_l1_g.id, - 'l1 input(a) refers parent/group', - ); - expect(input_l1_fb.getAttribute('aria-describedby')).to.contain( - msg_l1_g.id, - 'l1 input(b) refers parent/group', - ); - - // Also check that aria-describedby of the inputs are not overridden (this relation was - // put there in lion-input(using lion-field)). - expect(input_l1_fa.getAttribute('aria-describedby')).to.contain( - msg_l1_fa.id, - 'l1 input(a) refers local field', - ); - expect(input_l1_fb.getAttribute('aria-describedby')).to.contain( - msg_l1_fb.id, - 'l1 input(b) refers local field', - ); - - // Also make feedback element point to nested fieldset inputs - expect(input_l2_fa.getAttribute('aria-describedby')).to.contain( - msg_l1_g.id, - 'l2 input(a) refers grandparent/group.group', - ); - expect(input_l2_fb.getAttribute('aria-describedby')).to.contain( - msg_l1_g.id, - 'l2 input(b) refers grandparent/group.group', - ); - - // Check order: the nearest ('dom wise': so 1. local, 2. parent, 3. grandparent) message - // should be read first by screen reader - const dA = /** @type {string} */ (input_l2_fa.getAttribute('aria-describedby')); - expect( - // @ts-expect-error - dA.indexOf(msg_l2_fa.id) < dA.indexOf(msg_l2_g.id) < dA.indexOf(msg_l1_g.id), - ).to.equal(true, 'order of ids'); - const dB = input_l2_fb.getAttribute('aria-describedby'); - expect( - // @ts-expect-error - dB.indexOf(msg_l2_fb.id) < dB.indexOf(msg_l2_g.id) < dB.indexOf(msg_l1_g.id), - ).to.equal(true, 'order of ids'); - }; - }); - - it(`reads feedback message belonging to fieldset when child input is focused - (via aria-describedby)`, async () => { - childAriaTest(await childAriaFixture('feedback')); - }); - - it(`reads help-text message belonging to fieldset when child input is focused - (via aria-describedby)`, async () => { - childAriaTest(await childAriaFixture('help-text')); - }); - }); }); }); } diff --git a/packages/form-core/test/field-integrations.test.js b/packages/form-core/test/field-integrations.test.js deleted file mode 100644 index 3e61a1b8b..000000000 --- a/packages/form-core/test/field-integrations.test.js +++ /dev/null @@ -1,26 +0,0 @@ -import { defineCE } from '@open-wc/testing'; -import { runInteractionStateMixinSuite } from '../test-suites/InteractionStateMixin.suite.js'; -import { LionField } from '../src/LionField.js'; -import { runFormatMixinSuite } from '../test-suites/FormatMixin.suite.js'; - -const fieldTagString = defineCE( - class extends LionField { - get slots() { - return { - ...super.slots, - // LionField needs to have an _inputNode defined in order to work... - input: () => document.createElement('input'), - }; - } - }, -); - -describe(' integrations', () => { - runInteractionStateMixinSuite({ - tagString: fieldTagString, - }); - - runFormatMixinSuite({ - tagString: fieldTagString, - }); -}); diff --git a/packages/form-core/test/lion-field.test.js b/packages/form-core/test/lion-field.test.js index 2f064148b..e7bbc635a 100644 --- a/packages/form-core/test/lion-field.test.js +++ b/packages/form-core/test/lion-field.test.js @@ -3,7 +3,6 @@ import { localize } from '@lion/localize'; import { localizeTearDown } from '@lion/localize/test-helpers.js'; import { Required, Validator } from '@lion/form-core'; import { - aTimeout, expect, fixture, html, @@ -123,21 +122,6 @@ describe('', () => { expect(el.focused).to.equal(false); }); - it('can be disabled via attribute', async () => { - const elDisabled = /** @type {LionField} */ (await fixture( - html`<${tag} disabled>${inputSlot}`, - )); - expect(elDisabled.disabled).to.equal(true); - expect(elDisabled._inputNode.disabled).to.equal(true); - }); - - it('can be disabled via property', async () => { - const el = /** @type {LionField} */ (await fixture(html`<${tag}>${inputSlot}`)); - el.disabled = true; - await el.updateComplete; - expect(el._inputNode.disabled).to.equal(true); - }); - it('can be cleared which erases value, validation and interaction states', async () => { const el = /** @type {LionField} */ (await fixture( html`<${tag} value="Some value from attribute">${inputSlot}`, @@ -161,60 +145,6 @@ describe('', () => { expect(el.modelValue).to.equal('foo'); }); - it('reads initial value from attribute value', async () => { - const el = /** @type {LionField} */ (await fixture( - html`<${tag} value="one">${inputSlot}`, - )); - expect(getSlot(el, 'input').value).to.equal('one'); - }); - - it('delegates value property', async () => { - const el = /** @type {LionField} */ (await fixture(html`<${tag}>${inputSlot}`)); - expect(getSlot(el, 'input').value).to.equal(''); - el.value = 'one'; - expect(el.value).to.equal('one'); - expect(getSlot(el, 'input').value).to.equal('one'); - }); - - // This is necessary for security, so that _inputNodes autocomplete can be set to 'off' - it('delegates autocomplete property', async () => { - const el = /** @type {LionField} */ (await fixture(html`<${tag}>${inputSlot}`)); - expect(el._inputNode.autocomplete).to.equal(''); - expect(el._inputNode.hasAttribute('autocomplete')).to.be.false; - el.autocomplete = 'off'; - await el.updateComplete; - expect(el._inputNode.autocomplete).to.equal('off'); - expect(el._inputNode.getAttribute('autocomplete')).to.equal('off'); - }); - - it('preserves the caret position on value change for native text fields (input|textarea)', async () => { - const el = /** @type {LionField} */ (await fixture(html`<${tag}>${inputSlot}`)); - await triggerFocusFor(el); - await el.updateComplete; - el._inputNode.value = 'hello world'; - el._inputNode.selectionStart = 2; - el._inputNode.selectionEnd = 2; - el.value = 'hey there universe'; - expect(el._inputNode.selectionStart).to.equal(2); - expect(el._inputNode.selectionEnd).to.equal(2); - }); - - // TODO: Add test that css pointerEvents is none if disabled. - it('is disabled when disabled property is passed', async () => { - const el = /** @type {LionField} */ (await fixture(html`<${tag}>${inputSlot}`)); - expect(el._inputNode.hasAttribute('disabled')).to.equal(false); - - el.disabled = true; - await el.updateComplete; - await aTimeout(0); - - expect(el._inputNode.hasAttribute('disabled')).to.equal(true); - const disabledel = /** @type {LionField} */ (await fixture( - html`<${tag} disabled>${inputSlot}`, - )); - expect(disabledel._inputNode.hasAttribute('disabled')).to.equal(true); - }); - describe('Accessibility', () => { it(`by setting corresponding aria-labelledby (for label) and aria-describedby (for helpText, feedback) ~~~ @@ -437,37 +367,6 @@ describe('', () => { expect(disabledEl.validationStates.error).to.deep.equal({}); }); - it('should remove validation when disabled state toggles', async () => { - const HasX = class extends Validator { - static get validatorName() { - return 'HasX'; - } - - /** - * @param {string} value - */ - execute(value) { - const result = value.indexOf('x') === -1; - return result; - } - }; - const el = /** @type {LionField} */ (await fixture(html` - <${tag} - .validators=${[new HasX()]} - .modelValue=${'a@b.nl'} - > - ${inputSlot} - - `)); - expect(el.hasFeedbackFor).to.deep.equal(['error']); - expect(el.validationStates.error.HasX).to.exist; - - el.disabled = true; - await el.updateComplete; - expect(el.hasFeedbackFor).to.deep.equal([]); - expect(el.validationStates.error).to.deep.equal({}); - }); - it('can be required', async () => { const el = /** @type {LionField} */ (await fixture(html` <${tag} @@ -555,27 +454,4 @@ describe('', () => { }); }); }); - - describe('Delegation', () => { - it('delegates property value', async () => { - const el = /** @type {LionField} */ (await fixture(html`<${tag}>${inputSlot}`)); - expect(el._inputNode.value).to.equal(''); - el.value = 'one'; - expect(el.value).to.equal('one'); - expect(el._inputNode.value).to.equal('one'); - }); - - it('delegates property selectionStart and selectionEnd', async () => { - const el = /** @type {LionField} */ (await fixture(html` - <${tag} - .modelValue=${'Some text to select'} - >${unsafeHTML(inputSlotString)} - `)); - - el.selectionStart = 5; - el.selectionEnd = 12; - expect(el._inputNode.selectionStart).to.equal(5); - expect(el._inputNode.selectionEnd).to.equal(12); - }); - }); }); diff --git a/packages/form-core/types/ValueMixinTypes.d.ts b/packages/form-core/types/ValueMixinTypes.d.ts new file mode 100644 index 000000000..7b667d909 --- /dev/null +++ b/packages/form-core/types/ValueMixinTypes.d.ts @@ -0,0 +1,18 @@ +import { Constructor } from '@open-wc/dedupe-mixin'; +import { LionField } from '@lion/form-core/src/LionField'; + +export declare class LionFieldWithValue extends LionField { + get _inputNode(): HTMLTextAreaElement | HTMLInputElement | HTMLSelectElement; +} + +export declare class ValueHost { + get value(): string; + set value(value: string); + _setValueAndPreserveCaret(newValue: string): void; +} + +export declare function ValueImplementation>( + superclass: T, +): T & Constructor & ValueHost; + +export type ValueMixin = typeof ValueImplementation; diff --git a/packages/form-core/types/choice-group/ChoiceInputMixinTypes.d.ts b/packages/form-core/types/choice-group/ChoiceInputMixinTypes.d.ts index 88bd3fefc..a30ab7be1 100644 --- a/packages/form-core/types/choice-group/ChoiceInputMixinTypes.d.ts +++ b/packages/form-core/types/choice-group/ChoiceInputMixinTypes.d.ts @@ -63,7 +63,7 @@ export declare class ChoiceInputHost { type: string; - _inputNode: HTMLInputElement; + _inputNode: HTMLElement; } export declare function ChoiceInputImplementation>( diff --git a/packages/input/src/LionInput.js b/packages/input/src/LionInput.js index 829bea50d..18caf8557 100644 --- a/packages/input/src/LionInput.js +++ b/packages/input/src/LionInput.js @@ -1,4 +1,5 @@ import { LionField } from '@lion/form-core'; +import { ValueMixin } from '@lion/form-core/src/ValueMixin'; /** * LionInput: extension of lion-field with native input element in place and user friendly API. @@ -7,7 +8,7 @@ import { LionField } from '@lion/form-core'; * @extends {LionField} */ // @ts-expect-error false positive for incompatible static get properties. Lit-element merges super properties already for you. -export class LionInput extends LionField { +export class LionInput extends ValueMixin(LionField) { static get properties() { return { /** @@ -49,6 +50,42 @@ export class LionInput extends LionField { }; } + get _inputNode() { + return /** @type {HTMLInputElement} */ (super._inputNode); // casts type + } + + /** @type {number} */ + get selectionStart() { + const native = this._inputNode; + if (native && native.selectionStart) { + return native.selectionStart; + } + return 0; + } + + set selectionStart(value) { + const native = this._inputNode; + if (native && native.selectionStart) { + native.selectionStart = value; + } + } + + /** @type {number} */ + get selectionEnd() { + const native = this._inputNode; + if (native && native.selectionEnd) { + return native.selectionEnd; + } + return 0; + } + + set selectionEnd(value) { + const native = this._inputNode; + if (native && native.selectionEnd) { + native.selectionEnd = value; + } + } + constructor() { super(); this.readOnly = false; @@ -79,9 +116,23 @@ export class LionInput extends LionField { if (changedProperties.has('type')) { this._inputNode.type = this.type; } + if (changedProperties.has('placeholder')) { this._inputNode.placeholder = this.placeholder; } + + if (changedProperties.has('disabled')) { + this._inputNode.disabled = this.disabled; + this.validate(); + } + + if (changedProperties.has('name')) { + this._inputNode.name = this.name; + } + + if (changedProperties.has('autocomplete')) { + this._inputNode.autocomplete = /** @type {string} */ (this.autocomplete); + } } __delegateReadOnly() { diff --git a/packages/input/test/input-integrations.test.js b/packages/input/test/input-integrations.test.js new file mode 100644 index 000000000..44c47bcc2 --- /dev/null +++ b/packages/input/test/input-integrations.test.js @@ -0,0 +1,26 @@ +import { defineCE } from '@open-wc/testing'; +import { runInteractionStateMixinSuite } from '@lion/form-core/test-suites/InteractionStateMixin.suite.js'; +import { runFormatMixinSuite } from '@lion/form-core/test-suites/FormatMixin.suite.js'; +import { LionInput } from '../src/LionInput.js'; + +const fieldTagString = defineCE( + class extends LionInput { + get slots() { + return { + ...super.slots, + // LionInput needs to have an _inputNode defined in order to work... + input: () => document.createElement('input'), + }; + } + }, +); + +describe(' integrations', () => { + runInteractionStateMixinSuite({ + tagString: fieldTagString, + }); + + runFormatMixinSuite({ + tagString: fieldTagString, + }); +}); diff --git a/packages/input/test/lion-input.test.js b/packages/input/test/lion-input.test.js index bba50e0bb..2d2d6af8d 100644 --- a/packages/input/test/lion-input.test.js +++ b/packages/input/test/lion-input.test.js @@ -1,4 +1,5 @@ -import { expect, fixture, html, unsafeStatic } from '@open-wc/testing'; +import { Validator } from '@lion/form-core'; +import { expect, fixture, html, unsafeStatic, triggerFocusFor, aTimeout } from '@open-wc/testing'; import '../lion-input.js'; @@ -24,6 +25,81 @@ describe('', () => { expect(el._inputNode.getAttribute('value')).to.equal('prefilled'); }); + it('can be disabled via attribute', async () => { + const elDisabled = /** @type {LionInput} */ (await fixture(html`<${tag} disabled>`)); + expect(elDisabled.disabled).to.equal(true); + expect(elDisabled._inputNode.disabled).to.equal(true); + }); + + it('can be disabled via property', async () => { + const el = /** @type {LionInput} */ (await fixture(html`<${tag}>`)); + el.disabled = true; + await el.updateComplete; + expect(el._inputNode.disabled).to.equal(true); + }); + + // TODO: Add test that css pointerEvents is none if disabled. + it('is disabled when disabled property is passed', async () => { + const el = /** @type {LionInput} */ (await fixture(html`<${tag}>`)); + expect(el._inputNode.hasAttribute('disabled')).to.equal(false); + + el.disabled = true; + await el.updateComplete; + await aTimeout(0); + + expect(el._inputNode.hasAttribute('disabled')).to.equal(true); + const disabledel = /** @type {LionInput} */ (await fixture(html`<${tag} disabled>`)); + expect(disabledel._inputNode.hasAttribute('disabled')).to.equal(true); + }); + + it('reads initial value from attribute value', async () => { + const el = /** @type {LionInput} */ (await fixture(html`<${tag} value="one">`)); + expect( + /** @type {HTMLInputElement[]} */ (Array.from(el.children)).find( + child => child.slot === 'input', + )?.value, + ).to.equal('one'); + }); + + it('delegates value property', async () => { + const el = /** @type {LionInput} */ (await fixture(html`<${tag}>`)); + expect( + /** @type {HTMLInputElement[]} */ (Array.from(el.children)).find( + child => child.slot === 'input', + )?.value, + ).to.equal(''); + el.value = 'one'; + expect(el.value).to.equal('one'); + expect( + /** @type {HTMLInputElement[]} */ (Array.from(el.children)).find( + child => child.slot === 'input', + )?.value, + ).to.equal('one'); + }); + + // This is necessary for security, so that _inputNodes autocomplete can be set to 'off' + it('delegates autocomplete property', async () => { + const el = /** @type {LionInput} */ (await fixture(html`<${tag}>`)); + expect(el._inputNode.autocomplete).to.equal(''); + expect(el._inputNode.hasAttribute('autocomplete')).to.be.false; + el.autocomplete = 'off'; + await el.updateComplete; + expect(el._inputNode.autocomplete).to.equal('off'); + expect(el._inputNode.getAttribute('autocomplete')).to.equal('off'); + }); + + it('preserves the caret position on value change for native text fields (input|textarea)', async () => { + const el = /** @type {LionInput} */ (await fixture(html`<${tag}>`)); + await triggerFocusFor(el); + await el.updateComplete; + el._inputNode.value = 'hello world'; + el._inputNode.selectionStart = 2; + el._inputNode.selectionEnd = 2; + el.value = 'hey there universe'; + expect(el._inputNode.selectionStart).to.equal(2); + expect(el._inputNode.selectionEnd).to.equal(2); + }); + it('automatically creates an element if not provided by user', async () => { const el = /** @type {LionInput} */ (await fixture(html` <${tag}> @@ -54,6 +130,58 @@ describe('', () => { expect(el._inputNode.getAttribute('placeholder')).to.equal('foo'); }); + it('should remove validation when disabled state toggles', async () => { + const HasX = class extends Validator { + static get validatorName() { + return 'HasX'; + } + + /** + * @param {string} value + */ + execute(value) { + const result = value.indexOf('x') === -1; + return result; + } + }; + const el = /** @type {LionInput} */ (await fixture(html` + <${tag} + .validators=${[new HasX()]} + .modelValue=${'a@b.nl'} + > + `)); + expect(el.hasFeedbackFor).to.deep.equal(['error']); + expect(el.validationStates.error.HasX).to.exist; + + el.disabled = true; + await el.updateComplete; + expect(el.hasFeedbackFor).to.deep.equal([]); + expect(el.validationStates.error).to.deep.equal({}); + }); + + describe('Delegation', () => { + it('delegates property value', async () => { + const el = /** @type {LionInput} */ (await fixture(html`<${tag}>`)); + expect(el._inputNode.value).to.equal(''); + el.value = 'one'; + expect(el.value).to.equal('one'); + expect(el._inputNode.value).to.equal('one'); + }); + + it('delegates property selectionStart and selectionEnd', async () => { + const el = /** @type {LionInput} */ (await fixture(html` + <${tag} + .modelValue=${'Some text to select'} + > + `)); + + el.selectionStart = 5; + el.selectionEnd = 12; + expect(el._inputNode.selectionStart).to.equal(5); + expect(el._inputNode.selectionEnd).to.equal(12); + }); + }); + describe('Accessibility', () => { it('is accessible', async () => { const el = await fixture(html`<${tag} label="Label">`); diff --git a/packages/select/src/LionSelect.js b/packages/select/src/LionSelect.js index 91af142cd..152d8a8a3 100644 --- a/packages/select/src/LionSelect.js +++ b/packages/select/src/LionSelect.js @@ -1,4 +1,17 @@ +/* eslint-disable max-classes-per-file */ import { LionField } from '@lion/form-core'; +import { ValueMixin } from '@lion/form-core/src/ValueMixin'; + +class LionFieldWithSelect extends LionField { + /** + * @returns {HTMLSelectElement} + */ + get _inputNode() { + return /** @type {HTMLSelectElement} */ (Array.from(this.children).find( + el => el.slot === 'input', + )); + } +} /** * LionSelectNative: wraps the native HTML element select @@ -27,7 +40,7 @@ import { LionField } from '@lion/form-core'; * @customElement lion-select * @extends {LionField} */ -export class LionSelect extends LionField { +export class LionSelect extends ValueMixin(LionFieldWithSelect) { connectedCallback() { super.connectedCallback(); this._inputNode.addEventListener('change', this._proxyChangeEvent); diff --git a/packages/switch/src/LionSwitch.js b/packages/switch/src/LionSwitch.js index 09f54f296..177572e9d 100644 --- a/packages/switch/src/LionSwitch.js +++ b/packages/switch/src/LionSwitch.js @@ -29,10 +29,10 @@ export class LionSwitch extends ScopedElementsMixin(ChoiceInputMixin(LionField)) /** * Input node here is the lion-switch-button, which is not compatible with LionField _inputNode --> HTMLInputElement * Therefore we do a full override and typecast to an intersection type that includes LionSwitchButton - * @returns {HTMLInputElement & LionSwitchButton} + * @returns {LionSwitchButton} */ get _inputNode() { - return /** @type {HTMLInputElement & LionSwitchButton} */ (Array.from(this.children).find( + return /** @type {LionSwitchButton} */ (Array.from(this.children).find( el => el.slot === 'input', )); } diff --git a/packages/textarea/src/LionTextarea.js b/packages/textarea/src/LionTextarea.js index 20bf4ba92..2bf6a8409 100644 --- a/packages/textarea/src/LionTextarea.js +++ b/packages/textarea/src/LionTextarea.js @@ -1,16 +1,28 @@ +/* eslint-disable max-classes-per-file */ // @ts-expect-error https://github.com/jackmoore/autosize/pull/384 wait for this, then we can switch to just 'autosize'; and then types will work! import autosize from 'autosize/src/autosize.js'; import { LionField } from '@lion/form-core'; import { css } from '@lion/core'; +import { ValueMixin } from '@lion/form-core/src/ValueMixin'; + +class LionFieldWithTextArea extends LionField { + /** + * @returns {HTMLTextAreaElement} + */ + get _inputNode() { + return /** @type {HTMLTextAreaElement} */ (Array.from(this.children).find( + el => el.slot === 'input', + )); + } +} /** * LionTextarea: extension of lion-field with native input element in place and user friendly API * * @customElement lion-textarea - * @extends {LionField} */ -// @ts-expect-error false positive for incompatible static get properties. Lit-element merges super properties already for you. -export class LionTextarea extends LionField { +// @ts-expect-error false positive, parent properties get merged by lit-element already +export class LionTextarea extends ValueMixin(LionFieldWithTextArea) { static get properties() { return { maxRows: { @@ -49,17 +61,6 @@ export class LionTextarea extends LionField { }; } - /** - * Input node here is the textarea, which is not compatible with LionField _inputNode --> HTMLInputElement - * Therefore we do a full override and typecast to an intersection type that includes HTMLTextAreaElement - * @returns {HTMLTextAreaElement & HTMLInputElement} - */ - get _inputNode() { - return /** @type {HTMLTextAreaElement & HTMLInputElement} */ (Array.from(this.children).find( - el => el.slot === 'input', - )); - } - constructor() { super(); this.rows = 2; @@ -74,14 +75,14 @@ export class LionTextarea extends LionField { this.__initializeAutoresize(); } - disconnectedCallback() { - super.disconnectedCallback(); - autosize.destroy(this._inputNode); - } - /** @param {import('lit-element').PropertyValues } changedProperties */ updated(changedProperties) { super.updated(changedProperties); + if (changedProperties.has('disabled')) { + this._inputNode.disabled = this.disabled; + this.validate(); + } + if (changedProperties.has('rows')) { const native = this._inputNode; if (native) { @@ -112,6 +113,11 @@ export class LionTextarea extends LionField { } } + disconnectedCallback() { + super.disconnectedCallback(); + autosize.destroy(this._inputNode); + } + /** * To support maxRows we need to set max-height of the textarea */