Merge pull request #978 from ing-bank/fix/_inputNode
fix: assure proper typings _inputNode across lion fields
This commit is contained in:
commit
f99da587b7
23 changed files with 649 additions and 483 deletions
10
.changeset/small-pens-applaud.md
Normal file
10
.changeset/small-pens-applaud.md
Normal 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.
|
||||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -607,8 +607,7 @@ const FormControlMixinImplementation = superclass =>
|
|||
* @return {CSSResult | CSSResult[]}
|
||||
*/
|
||||
static get styles() {
|
||||
return [
|
||||
css`
|
||||
return css`
|
||||
/**********************
|
||||
{block} .form-field
|
||||
********************/
|
||||
|
|
@ -657,8 +656,7 @@ const FormControlMixinImplementation = superclass =>
|
|||
margin: 0; /* remove input margin in Safari */
|
||||
font-size: 100%; /* normalize default input font-size */
|
||||
}
|
||||
`,
|
||||
];
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* For instance, a localized date to a Date Object
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
super();
|
||||
this.name = '';
|
||||
|
|
@ -119,26 +66,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 +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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
89
packages/form-core/src/NativeTextFieldMixin.js
Normal file
89
packages/form-core/src/NativeTextFieldMixin.js
Normal 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);
|
||||
|
|
@ -125,6 +125,9 @@ const FormGroupMixinImplementation = superclass =>
|
|||
|
||||
constructor() {
|
||||
super();
|
||||
// inputNode = this, which always requires a value prop
|
||||
this.value = '';
|
||||
|
||||
this.disabled = false;
|
||||
this.submitted = false;
|
||||
this.dirty = false;
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ import { DisabledHost } from '@lion/core/types/DisabledMixinTypes';
|
|||
import { LionValidationFeedback } from '../src/validate/LionValidationFeedback';
|
||||
import { FormRegisteringHost } from './registration/FormRegisteringMixinTypes';
|
||||
|
||||
declare interface HTMLElementWithValue extends HTMLElement {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class FormControlHost {
|
||||
static get styles(): CSSResult | CSSResult[];
|
||||
/**
|
||||
|
|
@ -52,7 +56,7 @@ export class FormControlHost {
|
|||
get fieldName(): string;
|
||||
__fieldName: string | undefined;
|
||||
get slots(): SlotsMap;
|
||||
get _inputNode(): HTMLElement;
|
||||
get _inputNode(): HTMLElementWithValue;
|
||||
get _labelNode(): HTMLElement;
|
||||
get _helpTextNode(): HTMLElement;
|
||||
get _feedbackNode(): LionValidationFeedback | undefined;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ export declare class FormatHost {
|
|||
serializedValue: string;
|
||||
formatOn: string;
|
||||
formatOptions: FormatNumberOptions;
|
||||
value: string;
|
||||
__preventRecursiveTrigger: boolean;
|
||||
__isHandlingUserInput: boolean;
|
||||
|
||||
|
|
@ -18,6 +17,9 @@ export declare class FormatHost {
|
|||
serializer(v: unknown): string;
|
||||
deserializer(v: string): unknown;
|
||||
|
||||
get value(): string;
|
||||
set value(value: string);
|
||||
|
||||
_calculateValues(opts: { source: 'model' | 'serialized' | 'formatted' | null }): void;
|
||||
__callParser(value: string | undefined): object;
|
||||
__callFormatter(): string;
|
||||
|
|
|
|||
19
packages/form-core/types/NativeTextFieldMixinTypes.d.ts
vendored
Normal file
19
packages/form-core/types/NativeTextFieldMixinTypes.d.ts
vendored
Normal 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;
|
||||
|
|
@ -63,7 +63,7 @@ export declare class ChoiceInputHost {
|
|||
|
||||
type: string;
|
||||
|
||||
_inputNode: HTMLInputElement;
|
||||
_inputNode: HTMLElement;
|
||||
}
|
||||
|
||||
export declare function ChoiceInputImplementation<T extends Constructor<LitElement>>(
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
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.
|
||||
*
|
||||
* @customElement lion-input
|
||||
* @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 NativeTextFieldMixin(LionField) {
|
||||
static get properties() {
|
||||
return {
|
||||
/**
|
||||
|
|
@ -49,6 +49,10 @@ export class LionInput extends LionField {
|
|||
};
|
||||
}
|
||||
|
||||
get _inputNode() {
|
||||
return /** @type {HTMLInputElement} */ (super._inputNode); // casts type
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.readOnly = false;
|
||||
|
|
@ -79,9 +83,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() {
|
||||
|
|
|
|||
26
packages/input/test/input-integrations.test.js
Normal file
26
packages/input/test/input-integrations.test.js
Normal 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,
|
||||
});
|
||||
});
|
||||
|
|
@ -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}>`);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { LionOptions } from './LionOptions.js';
|
|||
// 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('../types/ListboxMixinTypes').ListboxMixin} ListboxMixin
|
||||
* @typedef {import('../types/ListboxMixinTypes').ListboxHost} ListboxHost
|
||||
|
|
@ -131,15 +132,15 @@ const ListboxMixinImplementation = superclass =>
|
|||
* @configure FormControlMixin
|
||||
*/
|
||||
get _inputNode() {
|
||||
return /** @type {HTMLElement} */ (this.querySelector('[slot="input"]'));
|
||||
return /** @type {HTMLElementWithValue} */ (this.querySelector('[slot="input"]'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @overridable
|
||||
* @type {LionOptions}
|
||||
*/
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,17 @@
|
|||
/* eslint-disable max-classes-per-file */
|
||||
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
|
||||
*
|
||||
|
|
@ -25,14 +37,48 @@ import { LionField } from '@lion/form-core';
|
|||
* usability for keyboard and screen reader users.
|
||||
*
|
||||
* @customElement lion-select
|
||||
* @extends {LionField}
|
||||
*/
|
||||
export class LionSelect extends LionField {
|
||||
export class LionSelect extends LionFieldWithSelect {
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
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() {
|
||||
super.disconnectedCallback();
|
||||
this._inputNode.removeEventListener('change', this._proxyChangeEvent);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,9 @@ export class LionSwitchButton extends DisabledWithTabIndexMixin(LitElement) {
|
|||
|
||||
constructor() {
|
||||
super();
|
||||
// inputNode = this, which always requires a value prop
|
||||
this.value = '';
|
||||
|
||||
this.role = 'switch';
|
||||
this.checked = false;
|
||||
this.__toggleChecked = this.__toggleChecked.bind(this);
|
||||
|
|
|
|||
|
|
@ -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 { 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
|
||||
*
|
||||
* @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 NativeTextFieldMixin(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,23 @@ 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('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')) {
|
||||
const native = this._inputNode;
|
||||
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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue