fix: assure proper typings _inputNode across lion fields

This commit is contained in:
Joren Broekema 2020-10-01 10:02:45 +02:00
parent c03ebde5b5
commit 0aa4480e0c
16 changed files with 535 additions and 434 deletions

View file

@ -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.

View file

@ -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' });

View file

@ -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
// <input type=number>)
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;
}
}
}

View file

@ -0,0 +1,57 @@
import { dedupeMixin } from '@lion/core';
/**
* @typedef {import('../types/ValueMixinTypes').ValueMixin} ValueMixin
* @type {ValueMixin}
* @param {import('@open-wc/dedupe-mixin').Constructor<import('../types/ValueMixinTypes').LionFieldWithValue>} 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
// <input type=number>)
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);

View file

@ -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">
<div slot="${msgSlotType}" id="msg_l1_fa"></div>
<!-- field referred by: #msg_l1_fa (local), #msg_l1_g (parent/group) -->
</${childTag}>
<${childTag} name="l1_fb">
<div slot="${msgSlotType}" id="msg_l1_fb"></div>
<!-- field referred by: #msg_l1_fb (local), #msg_l1_g (parent/group) -->
</${childTag}>
<!-- [ INNER FIELDSET ] -->
<${tag} name="l2_g">
<${childTag} name="l2_fa">
<div slot="${msgSlotType}" id="msg_l2_fa"></div>
<!-- field referred by: #msg_l2_fa (local), #msg_l2_g (parent/group), #msg_l1_g (grandparent/group.group) -->
</${childTag}>
<${childTag} name="l2_fb">
<div slot="${msgSlotType}" id="msg_l2_fb"></div>
<!-- field referred by: #msg_l2_fb (local), #msg_l2_g (parent/group), #msg_l1_g (grandparent/group.group) -->
</${childTag}>
<div slot="${msgSlotType}" id="msg_l2_g"></div>
<!-- group referred by: #msg_l2_g (local), #msg_l1_g (parent/group) -->
</${tag}>
<!-- [ / INNER FIELDSET ] -->
<div slot="${msgSlotType}" id="msg_l1_g"></div>
<!-- group referred by: #msg_l1_g (local) -->
</${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'));
});
});
});
}

View file

@ -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">
<div slot="${msgSlotType}" id="msg_l1_fa"></div>
<!-- field referred by: #msg_l1_fa (local), #msg_l1_g (parent/group) -->
</${childTag}>
<${childTag} name="l1_fb">
<div slot="${msgSlotType}" id="msg_l1_fb"></div>
<!-- field referred by: #msg_l1_fb (local), #msg_l1_g (parent/group) -->
</${childTag}>
<!-- [ INNER FIELDSET ] -->
<${tag} name="l2_g">
<${childTag} name="l2_fa">
<div slot="${msgSlotType}" id="msg_l2_fa"></div>
<!-- field referred by: #msg_l2_fa (local), #msg_l2_g (parent/group), #msg_l1_g (grandparent/group.group) -->
</${childTag}>
<${childTag} name="l2_fb">
<div slot="${msgSlotType}" id="msg_l2_fb"></div>
<!-- field referred by: #msg_l2_fb (local), #msg_l2_g (parent/group), #msg_l1_g (grandparent/group.group) -->
</${childTag}>
<div slot="${msgSlotType}" id="msg_l2_g"></div>
<!-- group referred by: #msg_l2_g (local), #msg_l1_g (parent/group) -->
</${tag}>
<!-- [ / INNER FIELDSET ] -->
<div slot="${msgSlotType}" id="msg_l1_g"></div>
<!-- group referred by: #msg_l1_g (local) -->
</${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'));
});
});
});
});
}

View file

@ -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('<lion-field> integrations', () => {
runInteractionStateMixinSuite({
tagString: fieldTagString,
});
runFormatMixinSuite({
tagString: fieldTagString,
});
});

View file

@ -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('<lion-field>', () => {
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('<lion-field>', () => {
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('<lion-field>', () => {
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('<lion-field>', () => {
});
});
});
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);
});
});
});

View file

@ -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<T extends Constructor<LionFieldWithValue>>(
superclass: T,
): T & Constructor<ValueHost> & ValueHost;
export type ValueMixin = typeof ValueImplementation;

View file

@ -63,7 +63,7 @@ export declare class ChoiceInputHost {
type: string;
_inputNode: HTMLInputElement;
_inputNode: HTMLElement;
}
export declare function ChoiceInputImplementation<T extends Constructor<LitElement>>(

View file

@ -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() {

View file

@ -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('<lion-input> integrations', () => {
runInteractionStateMixinSuite({
tagString: fieldTagString,
});
runFormatMixinSuite({
tagString: fieldTagString,
});
});

View file

@ -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('<lion-input>', () => {
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 <input> element if not provided by user', async () => {
const el = /** @type {LionInput} */ (await fixture(html`
<${tag}></${tag}>
@ -54,6 +130,58 @@ describe('<lion-input>', () => {
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}>`);

View file

@ -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);

View file

@ -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',
));
}

View file

@ -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
*/