Merge pull request #978 from ing-bank/fix/_inputNode

fix: assure proper typings _inputNode across lion fields
This commit is contained in:
Joren Broekema 2020-10-05 12:08:40 +02:00 committed by GitHub
commit f99da587b7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 649 additions and 483 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 { 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'; import '../lion-fieldset.js';
runFormGroupMixinSuite({ tagString: 'lion-fieldset' }); runFormGroupMixinSuite({ tagString: 'lion-fieldset' });
runFormGroupMixinInputSuite({ tagString: 'lion-fieldset' });

View file

@ -607,58 +607,56 @@ const FormControlMixinImplementation = superclass =>
* @return {CSSResult | CSSResult[]} * @return {CSSResult | CSSResult[]}
*/ */
static get styles() { static get styles() {
return [ return css`
css` /**********************
/**********************
{block} .form-field {block} .form-field
********************/ ********************/
:host { :host {
display: block; display: block;
} }
:host([hidden]) { :host([hidden]) {
display: none; display: none;
} }
:host([disabled]) { :host([disabled]) {
pointer-events: none; pointer-events: none;
} }
:host([disabled]) .form-field__label ::slotted(*), :host([disabled]) .form-field__label ::slotted(*),
:host([disabled]) .form-field__help-text ::slotted(*) { :host([disabled]) .form-field__help-text ::slotted(*) {
color: var(--disabled-text-color, #adadad); color: var(--disabled-text-color, #adadad);
} }
/*********************** /***********************
{block} .input-group {block} .input-group
*********************/ *********************/
.input-group__container { .input-group__container {
display: flex; display: flex;
} }
.input-group__input { .input-group__input {
flex: 1; flex: 1;
display: flex; display: flex;
} }
/***** {state} :disabled *****/ /***** {state} :disabled *****/
:host([disabled]) .input-group ::slotted(slot='input') { :host([disabled]) .input-group ::slotted(slot='input') {
color: var(--disabled-text-color, #adadad); color: var(--disabled-text-color, #adadad);
} }
/*********************** /***********************
{block} .form-control {block} .form-control
**********************/ **********************/
.input-group__container > .input-group__input ::slotted(.form-control) { .input-group__container > .input-group__input ::slotted(.form-control) {
flex: 1 1 auto; flex: 1 1 auto;
margin: 0; /* remove input margin in Safari */ margin: 0; /* remove input margin in Safari */
font-size: 100%; /* normalize default input font-size */ font-size: 100%; /* normalize default input font-size */
} }
`, `;
];
} }
/** /**

View file

@ -122,6 +122,23 @@ const FormatMixinImplementation = 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._inputNode.value = value;
/** @type {string | undefined} */
this.__value = undefined;
} else {
this.__value = value;
}
}
/** /**
* Converts formattedValue to modelValue * Converts formattedValue to modelValue
* For instance, a localized date to a Date Object * For instance, a localized date to a Date Object

View file

@ -38,59 +38,6 @@ 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 || '';
}
constructor() { constructor() {
super(); super();
this.name = ''; this.name = '';
@ -119,26 +66,6 @@ export class LionField extends FormControlMixin(
this._inputNode.removeEventListener('change', this._onChange); 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() { resetInteractionState() {
super.resetInteractionState(); super.resetInteractionState();
this.submitted = false; this.submitted = false;
@ -164,30 +91,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,89 @@
import { dedupeMixin } from '@lion/core';
/**
* @typedef {import('../types/NativeTextFieldMixinTypes').NativeTextFieldMixin} NativeTextFieldMixin
* @type {NativeTextFieldMixin}
* @param {import('@open-wc/dedupe-mixin').Constructor<import('../types/NativeTextFieldMixinTypes').NativeTextField>} superclass} superclass
*/
const NativeTextFieldMixinImplementation = superclass =>
class NativeTextFieldMixin extends superclass {
/** @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;
}
}
get value() {
return (this._inputNode && this._inputNode.value) || this.__value || '';
}
// We don't delegate, because we want to preserve caret position via _setValueAndPreserveCaret
/** @param {string} value */
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 NativeTextFieldMixin = dedupeMixin(NativeTextFieldMixinImplementation);

View file

@ -125,6 +125,9 @@ const FormGroupMixinImplementation = superclass =>
constructor() { constructor() {
super(); super();
// inputNode = this, which always requires a value prop
this.value = '';
this.disabled = false; this.disabled = false;
this.submitted = false; this.submitted = false;
this.dirty = false; this.dirty = false;

View file

@ -0,0 +1,196 @@
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}>
`));
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, aTimeout,
} from '@open-wc/testing'; } from '@open-wc/testing';
import sinon from 'sinon'; 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 '@lion/form-core/lion-field.js';
import { FormGroupMixin } from '../../src/form-group/FormGroupMixin.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 }); 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 () => { it('allows for nested fieldsets', async () => {
const fieldset = /** @type {FormGroup} */ (await fixture(html` const fieldset = /** @type {FormGroup} */ (await fixture(html`
<${tag} name="userData"> <${tag} name="userData">
@ -1119,153 +1104,6 @@ export function runFormGroupMixinSuite(cfg = {}) {
expect(el.hasAttribute('aria-labelledby')).to.equal(true); expect(el.hasAttribute('aria-labelledby')).to.equal(true);
expect(el.getAttribute('aria-labelledby')).contains(label.id); 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 { localizeTearDown } from '@lion/localize/test-helpers.js';
import { Required, Validator } from '@lion/form-core'; import { Required, Validator } from '@lion/form-core';
import { import {
aTimeout,
expect, expect,
fixture, fixture,
html, html,
@ -123,21 +122,6 @@ describe('<lion-field>', () => {
expect(el.focused).to.equal(false); 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 () => { it('can be cleared which erases value, validation and interaction states', async () => {
const el = /** @type {LionField} */ (await fixture( const el = /** @type {LionField} */ (await fixture(
html`<${tag} value="Some value from attribute">${inputSlot}</${tag}>`, html`<${tag} value="Some value from attribute">${inputSlot}</${tag}>`,
@ -161,60 +145,6 @@ describe('<lion-field>', () => {
expect(el.modelValue).to.equal('foo'); 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', () => { describe('Accessibility', () => {
it(`by setting corresponding aria-labelledby (for label) and aria-describedby (for helpText, feedback) 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({}); 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 () => { it('can be required', async () => {
const el = /** @type {LionField} */ (await fixture(html` const el = /** @type {LionField} */ (await fixture(html`
<${tag} <${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

@ -6,6 +6,10 @@ import { DisabledHost } from '@lion/core/types/DisabledMixinTypes';
import { LionValidationFeedback } from '../src/validate/LionValidationFeedback'; import { LionValidationFeedback } from '../src/validate/LionValidationFeedback';
import { FormRegisteringHost } from './registration/FormRegisteringMixinTypes'; import { FormRegisteringHost } from './registration/FormRegisteringMixinTypes';
declare interface HTMLElementWithValue extends HTMLElement {
value: string;
}
export class FormControlHost { export class FormControlHost {
static get styles(): CSSResult | CSSResult[]; static get styles(): CSSResult | CSSResult[];
/** /**
@ -52,7 +56,7 @@ export class FormControlHost {
get fieldName(): string; get fieldName(): string;
__fieldName: string | undefined; __fieldName: string | undefined;
get slots(): SlotsMap; get slots(): SlotsMap;
get _inputNode(): HTMLElement; get _inputNode(): HTMLElementWithValue;
get _labelNode(): HTMLElement; get _labelNode(): HTMLElement;
get _helpTextNode(): HTMLElement; get _helpTextNode(): HTMLElement;
get _feedbackNode(): LionValidationFeedback | undefined; get _feedbackNode(): LionValidationFeedback | undefined;

View file

@ -9,7 +9,6 @@ export declare class FormatHost {
serializedValue: string; serializedValue: string;
formatOn: string; formatOn: string;
formatOptions: FormatNumberOptions; formatOptions: FormatNumberOptions;
value: string;
__preventRecursiveTrigger: boolean; __preventRecursiveTrigger: boolean;
__isHandlingUserInput: boolean; __isHandlingUserInput: boolean;
@ -18,6 +17,9 @@ export declare class FormatHost {
serializer(v: unknown): string; serializer(v: unknown): string;
deserializer(v: string): unknown; deserializer(v: string): unknown;
get value(): string;
set value(value: string);
_calculateValues(opts: { source: 'model' | 'serialized' | 'formatted' | null }): void; _calculateValues(opts: { source: 'model' | 'serialized' | 'formatted' | null }): void;
__callParser(value: string | undefined): object; __callParser(value: string | undefined): object;
__callFormatter(): string; __callFormatter(): string;

View file

@ -0,0 +1,19 @@
import { Constructor } from '@open-wc/dedupe-mixin';
import { LionField } from '@lion/form-core/src/LionField';
export declare class NativeTextField extends LionField {
get _inputNode(): HTMLTextAreaElement | HTMLInputElement;
}
export declare class NativeTextFieldHost {
get selectionStart(): number;
set selectionStart(value: number);
get selectionEnd(): number;
set selectionEnd(value: number);
}
export declare function NativeTextFieldImplementation<T extends Constructor<NativeTextField>>(
superclass: T,
): T & Constructor<NativeTextFieldHost> & NativeTextFieldHost & typeof NativeTextField;
export type NativeTextFieldMixin = typeof NativeTextFieldImplementation;

View file

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

View file

@ -1,13 +1,13 @@
import { LionField } from '@lion/form-core'; import { LionField } from '@lion/form-core';
import { NativeTextFieldMixin } from '@lion/form-core/src/NativeTextFieldMixin';
/** /**
* LionInput: extension of lion-field with native input element in place and user friendly API. * LionInput: extension of lion-field with native input element in place and user friendly API.
* *
* @customElement lion-input * @customElement lion-input
* @extends {LionField}
*/ */
// @ts-expect-error false positive for incompatible static get properties. Lit-element merges super properties already for you. // @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 NativeTextFieldMixin(LionField) {
static get properties() { static get properties() {
return { return {
/** /**
@ -49,6 +49,10 @@ export class LionInput extends LionField {
}; };
} }
get _inputNode() {
return /** @type {HTMLInputElement} */ (super._inputNode); // casts type
}
constructor() { constructor() {
super(); super();
this.readOnly = false; this.readOnly = false;
@ -79,9 +83,23 @@ export class LionInput extends LionField {
if (changedProperties.has('type')) { if (changedProperties.has('type')) {
this._inputNode.type = this.type; this._inputNode.type = this.type;
} }
if (changedProperties.has('placeholder')) { if (changedProperties.has('placeholder')) {
this._inputNode.placeholder = this.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() { __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'; import '../lion-input.js';
@ -24,6 +25,81 @@ describe('<lion-input>', () => {
expect(el._inputNode.getAttribute('value')).to.equal('prefilled'); 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 () => { it('automatically creates an <input> element if not provided by user', async () => {
const el = /** @type {LionInput} */ (await fixture(html` const el = /** @type {LionInput} */ (await fixture(html`
<${tag}></${tag}> <${tag}></${tag}>
@ -54,6 +130,58 @@ describe('<lion-input>', () => {
expect(el._inputNode.getAttribute('placeholder')).to.equal('foo'); 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', () => { describe('Accessibility', () => {
it('is accessible', async () => { it('is accessible', async () => {
const el = await fixture(html`<${tag} label="Label"></${tag}>`); const el = await fixture(html`<${tag} label="Label"></${tag}>`);

View file

@ -9,6 +9,7 @@ import { LionOptions } from './LionOptions.js';
// list items that can be found via MutationObserver or registration (.formElements) // list items that can be found via MutationObserver or registration (.formElements)
/** /**
* @typedef {import('@lion/form-core/types/FormControlMixinTypes').HTMLElementWithValue} HTMLElementWithValue
* @typedef {import('./LionOption').LionOption} LionOption * @typedef {import('./LionOption').LionOption} LionOption
* @typedef {import('../types/ListboxMixinTypes').ListboxMixin} ListboxMixin * @typedef {import('../types/ListboxMixinTypes').ListboxMixin} ListboxMixin
* @typedef {import('../types/ListboxMixinTypes').ListboxHost} ListboxHost * @typedef {import('../types/ListboxMixinTypes').ListboxHost} ListboxHost
@ -131,15 +132,15 @@ const ListboxMixinImplementation = superclass =>
* @configure FormControlMixin * @configure FormControlMixin
*/ */
get _inputNode() { get _inputNode() {
return /** @type {HTMLElement} */ (this.querySelector('[slot="input"]')); return /** @type {HTMLElementWithValue} */ (this.querySelector('[slot="input"]'));
} }
/** /**
* @overridable * @overridable
* @type {LionOptions}
*/ */
get _listboxNode() { get _listboxNode() {
return /** @type {LionOptions} */ (this._inputNode); // Cast to unknown first, since HTMLElementWithValue is not compatible with LionOptions
return /** @type {LionOptions} */ (/** @type {unknown} */ (this._inputNode));
} }
/** /**

View file

@ -1,5 +1,17 @@
/* eslint-disable max-classes-per-file */
import { LionField } from '@lion/form-core'; import { LionField } from '@lion/form-core';
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 * LionSelectNative: wraps the native HTML element select
* *
@ -25,14 +37,48 @@ import { LionField } from '@lion/form-core';
* usability for keyboard and screen reader users. * usability for keyboard and screen reader users.
* *
* @customElement lion-select * @customElement lion-select
* @extends {LionField}
*/ */
export class LionSelect extends LionField { export class LionSelect extends LionFieldWithSelect {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this._inputNode.addEventListener('change', this._proxyChangeEvent); this._inputNode.addEventListener('change', this._proxyChangeEvent);
} }
// FIXME: For some reason we have to override this FormatMixin getter/setter pair for the tests to pass
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._inputNode.value = value;
/** @type {string | undefined} */
this.__value = undefined;
} else {
this.__value = value;
}
}
/** @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);
}
}
disconnectedCallback() { disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
this._inputNode.removeEventListener('change', this._proxyChangeEvent); this._inputNode.removeEventListener('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 * 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 * Therefore we do a full override and typecast to an intersection type that includes LionSwitchButton
* @returns {HTMLInputElement & LionSwitchButton} * @returns {LionSwitchButton}
*/ */
get _inputNode() { get _inputNode() {
return /** @type {HTMLInputElement & LionSwitchButton} */ (Array.from(this.children).find( return /** @type {LionSwitchButton} */ (Array.from(this.children).find(
el => el.slot === 'input', el => el.slot === 'input',
)); ));
} }

View file

@ -72,6 +72,9 @@ export class LionSwitchButton extends DisabledWithTabIndexMixin(LitElement) {
constructor() { constructor() {
super(); super();
// inputNode = this, which always requires a value prop
this.value = '';
this.role = 'switch'; this.role = 'switch';
this.checked = false; this.checked = false;
this.__toggleChecked = this.__toggleChecked.bind(this); this.__toggleChecked = this.__toggleChecked.bind(this);

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! // @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 autosize from 'autosize/src/autosize.js';
import { LionField } from '@lion/form-core'; import { LionField } from '@lion/form-core';
import { css } from '@lion/core'; import { css } from '@lion/core';
import { NativeTextFieldMixin } from '@lion/form-core/src/NativeTextFieldMixin';
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 * LionTextarea: extension of lion-field with native input element in place and user friendly API
* *
* @customElement lion-textarea * @customElement lion-textarea
* @extends {LionField}
*/ */
// @ts-expect-error false positive for incompatible static get properties. Lit-element merges super properties already for you. // @ts-expect-error false positive, parent properties get merged by lit-element already
export class LionTextarea extends LionField { export class LionTextarea extends NativeTextFieldMixin(LionFieldWithTextArea) {
static get properties() { static get properties() {
return { return {
maxRows: { 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() { constructor() {
super(); super();
this.rows = 2; this.rows = 2;
@ -74,14 +75,23 @@ export class LionTextarea extends LionField {
this.__initializeAutoresize(); this.__initializeAutoresize();
} }
disconnectedCallback() {
super.disconnectedCallback();
autosize.destroy(this._inputNode);
}
/** @param {import('lit-element').PropertyValues } changedProperties */ /** @param {import('lit-element').PropertyValues } changedProperties */
updated(changedProperties) { updated(changedProperties) {
super.updated(changedProperties); super.updated(changedProperties);
if (changedProperties.has('name')) {
this._inputNode.name = this.name;
}
if (changedProperties.has('autocomplete')) {
this._inputNode.autocomplete = /** @type {string} */ (this.autocomplete);
}
if (changedProperties.has('disabled')) {
this._inputNode.disabled = this.disabled;
this.validate();
}
if (changedProperties.has('rows')) { if (changedProperties.has('rows')) {
const native = this._inputNode; const native = this._inputNode;
if (native) { if (native) {
@ -112,6 +122,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 * To support maxRows we need to set max-height of the textarea
*/ */