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}>
+ <${childTag} name="custom[]">${childTag}>
+ ${tag}>
+ `));
+ 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}>
+
+ <${childTag} name="l1_fb">
+
+
+ ${childTag}>
+
+
+
+ <${tag} name="l2_g">
+ <${childTag} name="l2_fa">
+
+
+ ${childTag}>
+
+ <${childTag} name="l2_fb">
+
+
+ ${childTag}>
+
+
+
+ ${tag}>
+
+
+
+
+
+ ${tag}>
+ `));
+ 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}>
- <${childTag} name="custom[]">${childTag}>
- ${tag}>
- `));
- 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}>
-
- <${childTag} name="l1_fb">
-
-
- ${childTag}>
-
-
-
- <${tag} name="l2_g">
- <${childTag} name="l2_fa">
-
-
- ${childTag}>
-
- <${childTag} name="l2_fb">
-
-
- ${childTag}>
-
-
-
- ${tag}>
-
-
-
-
-
- ${tag}>
- `));
- 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}${tag}>`,
- ));
- 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}${tag}>`));
- 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}${tag}>`,
@@ -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}${tag}>`,
- ));
- expect(getSlot(el, 'input').value).to.equal('one');
- });
-
- it('delegates value property', async () => {
- const el = /** @type {LionField} */ (await fixture(html`<${tag}>${inputSlot}${tag}>`));
- 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}${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 {LionField} */ (await fixture(html`<${tag}>${inputSlot}${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);
- });
-
- // 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}${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 {LionField} */ (await fixture(
- html`<${tag} disabled>${inputSlot}${tag}>`,
- ));
- 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}
- ${tag}>
- `));
- 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}${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 {LionField} */ (await fixture(html`
- <${tag}
- .modelValue=${'Some text to select'}
- >${unsafeHTML(inputSlotString)}${tag}>
- `));
-
- 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>${tag}>`));
+ 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}>${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}>${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>${tag}>`));
+ 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">${tag}>`));
+ 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}>${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}>${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}>${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}>${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'}
+ >${tag}>
+ `));
+ 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}>${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'}
+ >${tag}>
+ `));
+
+ 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">${tag}>`);
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
*/