From 83e1bce5f99dc79b6222c5c1867269d9f19ce1da Mon Sep 17 00:00:00 2001 From: Joren Broekema Date: Thu, 6 Aug 2020 16:34:17 +0200 Subject: [PATCH 1/6] fix(core): fix types for core and commit index.d.ts re-exports --- .gitignore | 3 +- packages/core/index.d.ts | 51 +++++++++++++++++++ packages/core/test/DelegateMixin.test.js | 2 +- packages/core/types/DelegateMixinTypes.d.ts | 6 ++- packages/core/types/DisabledMixinTypes.d.ts | 3 +- .../types/DisabledWithTabIndexMixinTypes.d.ts | 5 +- packages/core/types/SlotMixinTypes.d.ts | 2 + 7 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 packages/core/index.d.ts diff --git a/.gitignore b/.gitignore index c0ec93709..3215bf147 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,8 @@ yarn-error.log ## types *.d.ts -!packages/*/types/* +!packages/**/*/types/**/* +!packages/**/index.d.ts ## temp folders /.tmp/ diff --git a/packages/core/index.d.ts b/packages/core/index.d.ts new file mode 100644 index 000000000..901842b0d --- /dev/null +++ b/packages/core/index.d.ts @@ -0,0 +1,51 @@ +export { asyncAppend } from 'lit-html/directives/async-append.js'; +export { asyncReplace } from 'lit-html/directives/async-replace.js'; +export { cache } from 'lit-html/directives/cache.js'; +export { classMap } from 'lit-html/directives/class-map.js'; +export { guard } from 'lit-html/directives/guard.js'; +export { ifDefined } from 'lit-html/directives/if-defined.js'; +export { repeat } from 'lit-html/directives/repeat.js'; +export { styleMap } from 'lit-html/directives/style-map.js'; +export { unsafeHTML } from 'lit-html/directives/unsafe-html.js'; +export { until } from 'lit-html/directives/until.js'; +export { render as renderShady } from 'lit-html/lib/shady-render.js'; +export { ScopedElementsMixin } from '@open-wc/scoped-elements'; +export { dedupeMixin } from '@open-wc/dedupe-mixin'; +export { DelegateMixin } from './src/DelegateMixin.js'; +export { DisabledMixin } from './src/DisabledMixin.js'; +export { DisabledWithTabIndexMixin } from './src/DisabledWithTabIndexMixin.js'; +export { SlotMixin } from './src/SlotMixin.js'; +export { UpdateStylesMixin } from './src/UpdateStylesMixin.js'; +export { browserDetection } from './src/browserDetection.js'; +export { + css, + CSSResult, + customElement, + defaultConverter, + eventOptions, + LitElement, + notEqual, + property, + query, + queryAll, + supportsAdoptingStyleSheets, + unsafeCSS, + UpdatingElement, +} from 'lit-element'; +export { + AttributePart, + BooleanAttributePart, + directive, + EventPart, + html, + isDirective, + isPrimitive, + noChange, + NodePart, + nothing, + PropertyPart, + render, + svg, + SVGTemplateResult, + TemplateResult, +} from 'lit-html'; diff --git a/packages/core/test/DelegateMixin.test.js b/packages/core/test/DelegateMixin.test.js index a9265bf7e..e459fd605 100644 --- a/packages/core/test/DelegateMixin.test.js +++ b/packages/core/test/DelegateMixin.test.js @@ -379,7 +379,7 @@ describe('DelegateMixin', () => { }); it('works with connectedCallback', async () => { - class ConnectedElement extends DelegateMixin(HTMLElement) { + class ConnectedElement extends DelegateMixin(LitElement) { get delegations() { return { ...super.delegations, diff --git a/packages/core/types/DelegateMixinTypes.d.ts b/packages/core/types/DelegateMixinTypes.d.ts index 0840c3a64..3a7e7ad7a 100644 --- a/packages/core/types/DelegateMixinTypes.d.ts +++ b/packages/core/types/DelegateMixinTypes.d.ts @@ -1,4 +1,5 @@ import { Constructor } from '@open-wc/dedupe-mixin'; +import { LitElement } from '../index.js'; export type Delegations = { target: Function; @@ -20,6 +21,9 @@ export declare class DelegateMixinHost { private __emptyEventListenerQueue(): void; private __emptyPropertiesQueue(): void; + + connectedCallback(): void; + updated(changedProperties: import('lit-element').PropertyValues): void; } /** @@ -44,7 +48,7 @@ export declare class DelegateMixinHost { * `; * } */ -declare function DelegateMixinImplementation>( +declare function DelegateMixinImplementation>( superclass: T, ): T & Constructor; diff --git a/packages/core/types/DisabledMixinTypes.d.ts b/packages/core/types/DisabledMixinTypes.d.ts index ade1a1178..872c10efb 100644 --- a/packages/core/types/DisabledMixinTypes.d.ts +++ b/packages/core/types/DisabledMixinTypes.d.ts @@ -1,4 +1,5 @@ import { Constructor } from '@open-wc/dedupe-mixin'; +import { LitElement } from 'lit-element'; export declare class DisabledMixinHost { static get properties(): { @@ -22,7 +23,7 @@ export declare class DisabledMixinHost { private __internalSetDisabled(value: boolean): void; } -export declare function DisabledMixinImplementation>( +export declare function DisabledMixinImplementation>( superclass: T, ): T & Constructor; diff --git a/packages/core/types/DisabledWithTabIndexMixinTypes.d.ts b/packages/core/types/DisabledWithTabIndexMixinTypes.d.ts index 358005a3b..0e3083409 100644 --- a/packages/core/types/DisabledWithTabIndexMixinTypes.d.ts +++ b/packages/core/types/DisabledWithTabIndexMixinTypes.d.ts @@ -1,5 +1,6 @@ import { Constructor } from '@open-wc/dedupe-mixin'; import { DisabledMixinHost } from './DisabledMixinTypes'; +import { LitElement } from 'lit-element'; export declare class DisabledWithTabIndexMixinHost { static get properties(): { tabIndex: { @@ -20,9 +21,11 @@ export declare class DisabledWithTabIndexMixinHost { public retractRequestToBeDisabled(): void; private __internalSetTabIndex(value: boolean): void; + + firstUpdated(changedProperties: import('lit-element').PropertyValues): void; } -export declare function DisabledWithTabIndexMixinImplementation>( +export declare function DisabledWithTabIndexMixinImplementation>( superclass: T, ): T & Constructor & Constructor; diff --git a/packages/core/types/SlotMixinTypes.d.ts b/packages/core/types/SlotMixinTypes.d.ts index d911b267f..67e1c5844 100644 --- a/packages/core/types/SlotMixinTypes.d.ts +++ b/packages/core/types/SlotMixinTypes.d.ts @@ -25,6 +25,8 @@ export declare class SlotMixinHost { * @return {boolean} true if given slot name been created by SlotMixin */ protected _isPrivateSlot(slotName: string): boolean; + + connectedCallback(): void; } /** From 53f59d5b7898465a4ecafa69c12a695f3b271b4e Mon Sep 17 00:00:00 2001 From: Joren Broekema Date: Thu, 6 Aug 2020 16:35:28 +0200 Subject: [PATCH 2/6] fix(localize): add re-exports d.ts to git --- packages/localize/index.d.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 packages/localize/index.d.ts diff --git a/packages/localize/index.d.ts b/packages/localize/index.d.ts new file mode 100644 index 000000000..9f0771159 --- /dev/null +++ b/packages/localize/index.d.ts @@ -0,0 +1,16 @@ +export { formatDate } from "./src/date/formatDate.js"; +export { getDateFormatBasedOnLocale } from "./src/date/getDateFormatBasedOnLocale.js"; +export { getMonthNames } from "./src/date/getMonthNames.js"; +export { getWeekdayNames } from "./src/date/getWeekdayNames.js"; +export { normalizeDateTime } from "./src/date/normalizeDateTime.js"; +export { parseDate } from "./src/date/parseDate.js"; +export { formatNumber } from "./src/number/formatNumber.js"; +export { formatNumberToParts } from "./src/number/formatNumberToParts.js"; +export { getCurrencyName } from "./src/number/getCurrencyName.js"; +export { getDecimalSeparator } from "./src/number/getDecimalSeparator.js"; +export { getFractionDigits } from "./src/number/getFractionDigits.js"; +export { getGroupSeparator } from "./src/number/getGroupSeparator.js"; +export { LocalizeManager } from "./src/LocalizeManager.js"; +export { LocalizeMixin } from "./src/LocalizeMixin.js"; +export { normalizeCurrencyLabel } from "./src/number/normalizeCurrencyLabel.js"; +export { localize, setLocalize } from "./src/localize.js"; From af5233121114cfd2a0696ff7c84a9192d470a21c Mon Sep 17 00:00:00 2001 From: Joren Broekema Date: Thu, 6 Aug 2020 16:35:52 +0200 Subject: [PATCH 3/6] fix(tabs): add re-exports d.ts to git --- packages/tabs/index.d.ts | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/tabs/index.d.ts diff --git a/packages/tabs/index.d.ts b/packages/tabs/index.d.ts new file mode 100644 index 000000000..96d683b2d --- /dev/null +++ b/packages/tabs/index.d.ts @@ -0,0 +1 @@ +export { LionTabs } from "./src/LionTabs.js"; From ecbe093494c4d2308a744852eb5c20f5079e6765 Mon Sep 17 00:00:00 2001 From: Joren Broekema Date: Thu, 6 Aug 2020 16:36:31 +0200 Subject: [PATCH 4/6] fix(singleton-manager): add re-exports d.ts to git --- packages/singleton-manager/index.d.ts | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 packages/singleton-manager/index.d.ts diff --git a/packages/singleton-manager/index.d.ts b/packages/singleton-manager/index.d.ts new file mode 100644 index 000000000..cf46846ea --- /dev/null +++ b/packages/singleton-manager/index.d.ts @@ -0,0 +1,3 @@ +export { SingletonManagerClass } from "./src/SingletonManagerClass.js"; +export const singletonManager: SingletonManagerClass; +import { SingletonManagerClass } from "./src/SingletonManagerClass.js"; From 74f51e1ef8c1167d82a2083ae3bf7b8515b5204e Mon Sep 17 00:00:00 2001 From: Joren Broekema Date: Thu, 6 Aug 2020 16:37:07 +0200 Subject: [PATCH 5/6] chore: increase mocha timeouts for slow a11y tests --- web-test-runner-browserstack.config.js | 5 +++++ web-test-runner.config.js | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/web-test-runner-browserstack.config.js b/web-test-runner-browserstack.config.js index dfb502dc6..ff2a0a997 100644 --- a/web-test-runner-browserstack.config.js +++ b/web-test-runner-browserstack.config.js @@ -29,6 +29,11 @@ module.exports = { lines: 90, }, }, + testFramework: { + config: { + timeout: '3000', + }, + }, browsers: [ // browserstackLauncher({ // capabilities: { diff --git a/web-test-runner.config.js b/web-test-runner.config.js index 04bdee22f..3f7936cdc 100644 --- a/web-test-runner.config.js +++ b/web-test-runner.config.js @@ -15,4 +15,9 @@ module.exports = { lines: 90, }, }, + testFramework: { + config: { + timeout: '3000', + }, + }, }; From 3c61fd294a99e5ce9ae568b8f54e77438180ee60 Mon Sep 17 00:00:00 2001 From: Joren Broekema Date: Thu, 6 Aug 2020 16:37:34 +0200 Subject: [PATCH 6/6] feat(form-core): add types for many form-core mixins and tests --- .changeset/green-planets-trade.md | 10 + packages/fieldset/test/lion-fieldset.test.js | 16 +- packages/form-core/src/FocusMixin.js | 179 +-- packages/form-core/src/FormControlMixin.js | 1338 +++++++++-------- packages/form-core/src/FormatMixin.js | 643 ++++---- .../form-core/src/InteractionStateMixin.js | 275 ++-- packages/form-core/src/LionField.js | 3 + .../src/form-group/FormGroupMixin.js | 2 +- .../registration/FormControlsCollection.js | 5 +- .../src/registration/FormRegisteringMixin.js | 52 +- .../src/registration/FormRegistrarMixin.js | 262 ++-- .../registration/FormRegistrarPortalMixin.js | 66 +- packages/form-core/src/utils/AsyncQueue.js | 10 +- .../form-core/src/utils/SyncUpdatableMixin.js | 144 +- .../src/utils/fakeExtendsEventTarget.js | 34 +- .../utils/getAriaElementsInRightDomOrder.js | 11 +- packages/form-core/src/utils/pascalCase.js | 7 +- .../form-core/src/validate/Unparseable.js | 1 + .../FormRegistrationMixins.suite.js | 128 +- .../test-suites/FormatMixin.suite.js | 225 +-- .../InteractionStateMixin.suite.js | 103 +- packages/form-core/test/FocusMixin.test.js | 49 +- .../form-core/test/FormControlMixin.test.js | 163 +- packages/form-core/test/lion-field.test.js | 102 +- .../test/utils/SyncUpdatableMixin.test.js | 334 ++-- .../getAriaElementsInRightDomOrder.test.js | 6 +- packages/form-core/types/FocusMixinTypes.d.ts | 28 + .../types/FormControlMixinTypes.d.ts | 104 ++ .../form-core/types/FormatMixinTypes.d.ts | 52 + .../types/InteractionStateMixinTypes.d.ts | 48 + .../FormRegisteringMixinTypes.d.ts | 12 + .../registration/FormRegistrarMixinTypes.d.ts | 27 + .../FormRegistrarPortalMixinTypes.d.ts | 11 + .../types/utils/SyncUpdatableMixinTypes.d.ts | 27 + tsconfig.json | 14 +- web-test-runner-browserstack.config.js | 2 +- 36 files changed, 2618 insertions(+), 1875 deletions(-) create mode 100644 .changeset/green-planets-trade.md create mode 100644 packages/form-core/types/FocusMixinTypes.d.ts create mode 100644 packages/form-core/types/FormControlMixinTypes.d.ts create mode 100644 packages/form-core/types/FormatMixinTypes.d.ts create mode 100644 packages/form-core/types/InteractionStateMixinTypes.d.ts create mode 100644 packages/form-core/types/registration/FormRegisteringMixinTypes.d.ts create mode 100644 packages/form-core/types/registration/FormRegistrarMixinTypes.d.ts create mode 100644 packages/form-core/types/registration/FormRegistrarPortalMixinTypes.d.ts create mode 100644 packages/form-core/types/utils/SyncUpdatableMixinTypes.d.ts diff --git a/.changeset/green-planets-trade.md b/.changeset/green-planets-trade.md new file mode 100644 index 000000000..db3d53e92 --- /dev/null +++ b/.changeset/green-planets-trade.md @@ -0,0 +1,10 @@ +--- +'@lion/form-core': minor +'@lion/core': patch +'@lion/fieldset': patch +'@lion/localize': patch +'singleton-manager': patch +'@lion/tabs': patch +--- + +Add types to form-core, for everything except form-group, choice-group and validate. Also added index.d.ts (re-)export files to git so that interdependent packages can use their types locally. diff --git a/packages/fieldset/test/lion-fieldset.test.js b/packages/fieldset/test/lion-fieldset.test.js index 3b376c07f..613c34efb 100644 --- a/packages/fieldset/test/lion-fieldset.test.js +++ b/packages/fieldset/test/lion-fieldset.test.js @@ -64,10 +64,10 @@ describe('', () => { // TODO: Tests below belong to FormRegistrarMixin. Preferably run suite integration test it(`${tagString} has an up to date list of every form element in .formElements`, async () => { const el = await fixture(html`<${tag}>${inputSlots}`); - expect(el.formElements.keys().length).to.equal(3); + expect(el.formElements._keys().length).to.equal(3); expect(el.formElements['hobbies[]'].length).to.equal(2); el.removeChild(el.formElements['hobbies[]'][0]); - expect(el.formElements.keys().length).to.equal(3); + expect(el.formElements._keys().length).to.equal(3); expect(el.formElements['hobbies[]'].length).to.equal(1); }); @@ -90,7 +90,7 @@ describe('', () => { el.formElements['hobbies[]'][0].modelValue = { checked: false, value: 'chess' }; el.formElements['hobbies[]'][1].modelValue = { checked: false, value: 'rugby' }; - expect(el.formElements.keys().length).to.equal(3); + expect(el.formElements._keys().length).to.equal(3); expect(el.formElements['hobbies[]'].length).to.equal(2); expect(el.formElements['hobbies[]'][0].modelValue.value).to.equal('chess'); expect(el.formElements['gender[]'][0].modelValue.value).to.equal('male'); @@ -165,13 +165,13 @@ describe('', () => { const el = await fixture(html`<${tag}>${inputSlots}`); const newField = await fixture(html`<${childTag} name="lastName">`); - expect(el.formElements.keys().length).to.equal(3); + expect(el.formElements._keys().length).to.equal(3); el.appendChild(newField); - expect(el.formElements.keys().length).to.equal(4); + expect(el.formElements._keys().length).to.equal(4); el._inputNode.removeChild(newField); - expect(el.formElements.keys().length).to.equal(3); + expect(el.formElements._keys().length).to.equal(3); }); // TODO: Tests below belong to FormGroupMixin. Preferably run suite integration test @@ -678,8 +678,8 @@ describe('', () => { newFieldset.formElements['gender[]'][1].modelValue = { checked: false, value: 'female' }; newFieldset.formElements.color.modelValue = { checked: false, value: 'blue' }; fieldset.formElements.comment.modelValue = 'Foo'; - expect(fieldset.formElements.keys().length).to.equal(2); - expect(newFieldset.formElements.keys().length).to.equal(3); + expect(fieldset.formElements._keys().length).to.equal(2); + expect(newFieldset.formElements._keys().length).to.equal(3); expect(fieldset.serializedValue).to.deep.equal({ comment: 'Foo', newfieldset: { diff --git a/packages/form-core/src/FocusMixin.js b/packages/form-core/src/FocusMixin.js index 97e8a909a..1f83cf146 100644 --- a/packages/form-core/src/FocusMixin.js +++ b/packages/form-core/src/FocusMixin.js @@ -1,102 +1,111 @@ import { dedupeMixin } from '@lion/core'; +/** + * @typedef {import('../types/FocusMixinTypes').FocusMixin} FocusMixin + * @type {FocusMixin} + */ +const FocusMixinImplementation = superclass => + // eslint-disable-next-line no-unused-vars, max-len, no-shadow + class FocusMixin extends superclass { + static get properties() { + return { + focused: { + type: Boolean, + reflect: true, + }, + }; + } -export const FocusMixin = dedupeMixin( - superclass => - // eslint-disable-next-line no-unused-vars, max-len, no-shadow - class FocusMixin extends superclass { - static get properties() { - return { - focused: { - type: Boolean, - reflect: true, - }, - }; + constructor() { + super(); + this.focused = false; + } + + connectedCallback() { + if (super.connectedCallback) { + super.connectedCallback(); } + this.__registerEventsForFocusMixin(); + } - constructor() { - super(); - this.focused = false; + disconnectedCallback() { + if (super.disconnectedCallback) { + super.disconnectedCallback(); } + this.__teardownEventsForFocusMixin(); + } - connectedCallback() { - if (super.connectedCallback) { - super.connectedCallback(); - } - this.__registerEventsForFocusMixin(); + focus() { + const native = this._inputNode; + if (native) { + native.focus(); } + } - disconnectedCallback() { - if (super.disconnectedCallback) { - super.disconnectedCallback(); - } - this.__teardownEventsForFocusMixin(); + blur() { + const native = this._inputNode; + if (native) { + native.blur(); } + } - focus() { - const native = this._inputNode; - if (native) { - native.focus(); - } - } + __onFocus() { + this.focused = true; + } - blur() { - const native = this._inputNode; - if (native) { - native.blur(); - } - } + __onBlur() { + this.focused = false; + } - __onFocus() { - if (super.__onFocus) { - super.__onFocus(); - } - this.focused = true; - } + __registerEventsForFocusMixin() { + /** + * focus + * @param {Event} ev + */ + this.__redispatchFocus = ev => { + ev.stopPropagation(); + this.dispatchEvent(new Event('focus')); + }; + this._inputNode.addEventListener('focus', this.__redispatchFocus); - __onBlur() { - if (super.__onBlur) { - super.__onBlur(); - } - this.focused = false; - } + /** + * blur + * @param {Event} ev + */ + this.__redispatchBlur = ev => { + ev.stopPropagation(); + this.dispatchEvent(new Event('blur')); + }; + this._inputNode.addEventListener('blur', this.__redispatchBlur); - __registerEventsForFocusMixin() { - // focus - this.__redispatchFocus = ev => { - ev.stopPropagation(); - this.dispatchEvent(new Event('focus')); - }; - this._inputNode.addEventListener('focus', this.__redispatchFocus); + /** + * focusin + * @param {Event} ev + */ + this.__redispatchFocusin = ev => { + ev.stopPropagation(); + this.__onFocus(); + this.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); + }; + this._inputNode.addEventListener('focusin', this.__redispatchFocusin); - // blur - this.__redispatchBlur = ev => { - ev.stopPropagation(); - this.dispatchEvent(new Event('blur')); - }; - this._inputNode.addEventListener('blur', this.__redispatchBlur); + /** + * focusout + * @param {Event} ev + */ + this.__redispatchFocusout = ev => { + ev.stopPropagation(); + this.__onBlur(); + this.dispatchEvent(new Event('focusout', { bubbles: true, composed: true })); + }; + this._inputNode.addEventListener('focusout', this.__redispatchFocusout); + } - // focusin - this.__redispatchFocusin = ev => { - ev.stopPropagation(); - this.__onFocus(ev); - this.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); - }; - this._inputNode.addEventListener('focusin', this.__redispatchFocusin); + __teardownEventsForFocusMixin() { + this._inputNode.removeEventListener('focus', this.__redispatchFocus); + this._inputNode.removeEventListener('blur', this.__redispatchBlur); + this._inputNode.removeEventListener('focusin', this.__redispatchFocusin); + this._inputNode.removeEventListener('focusout', this.__redispatchFocusout); + } + }; - // focusout - this.__redispatchFocusout = ev => { - ev.stopPropagation(); - this.__onBlur(); - this.dispatchEvent(new Event('focusout', { bubbles: true, composed: true })); - }; - this._inputNode.addEventListener('focusout', this.__redispatchFocusout); - } - - __teardownEventsForFocusMixin() { - this._inputNode.removeEventListener('focus', this.__redispatchFocus); - this._inputNode.removeEventListener('blur', this.__redispatchBlur); - this._inputNode.removeEventListener('focusin', this.__redispatchFocusin); - this._inputNode.removeEventListener('focusout', this.__redispatchFocusout); - } - }, -); +export const FocusMixin = dedupeMixin(FocusMixinImplementation); diff --git a/packages/form-core/src/FormControlMixin.js b/packages/form-core/src/FormControlMixin.js index df9ad76f0..ff9c923d1 100644 --- a/packages/form-core/src/FormControlMixin.js +++ b/packages/form-core/src/FormControlMixin.js @@ -17,666 +17,766 @@ function uuid(prefix) { * This Mixin is a shared fundament for all form components, it's applied on: * - LionField (which is extended to LionInput, LionTextarea, LionSelect etc. etc.) * - LionFieldset (which is extended to LionRadioGroup, LionCheckboxGroup, LionForm) - * - * @polymerMixin - * @mixinFunction + * @typedef {import('lit-html').TemplateResult} TemplateResult + * @typedef {import('lit-element').CSSResult} CSSResult + * @typedef {import('lit-html').nothing} nothing + * @typedef {import('@lion/core/types/SlotMixinTypes').SlotsMap} SlotsMap + * @typedef {import('../types/FormControlMixinTypes.js').FormControlMixin} FormControlMixin + * @type {FormControlMixin} */ -export const FormControlMixin = dedupeMixin( - superclass => - // eslint-disable-next-line no-shadow, no-unused-vars - class FormControlMixin extends FormRegisteringMixin(SlotMixin(superclass)) { - static get properties() { - return { - /** - * The name the element will be registered on to the .formElements collection - * of the parent. - */ - name: { - type: String, - reflect: true, - }, - /** - * When no light dom defined and prop set - */ - label: String, - /** - * When no light dom defined and prop set - */ - helpText: { - type: String, - attribute: 'help-text', - }, - /** - * Contains all elements that should end up in aria-labelledby of `._inputNode` - */ - _ariaLabelledNodes: Array, - /** - * Contains all elements that should end up in aria-describedby of `._inputNode` - */ - _ariaDescribedNodes: Array, - /** - * Based on the role, details of handling model-value-changed repropagation differ. - * @type {'child'|'fieldset'|'choice-group'} - */ - _repropagationRole: String, - /** - * By default, a field with _repropagationRole 'choice-group' will act as an - * 'endpoint'. This means it will be considered as an individual field: for - * a select, individual options will not be part of the formPath. They - * will. - * Similarly, components that (a11y wise) need to be fieldsets, but 'interaction wise' - * (from Application Developer perspective) need to be more like fields - * (think of an amount-input with a currency select box next to it), can set this - * to true to hide private internals in the formPath. - */ - _isRepropagationEndpoint: Boolean, - }; +const FormControlMixinImplementation = superclass => + // eslint-disable-next-line no-shadow, no-unused-vars + class FormControlMixin extends FormRegisteringMixin(SlotMixin(superclass)) { + static get properties() { + return { + /** + * The name the element will be registered on to the .formElements collection + * of the parent. + */ + name: { + type: String, + reflect: true, + }, + /** + * When no light dom defined and prop set + */ + label: String, // FIXME: { attribute: false } breaks a bunch of tests, but shouldn't... + /** + * When no light dom defined and prop set + */ + helpText: { + type: String, + attribute: 'help-text', + }, + /** + * Contains all elements that should end up in aria-labelledby of `._inputNode` + */ + _ariaLabelledNodes: { attribute: false }, + /** + * Contains all elements that should end up in aria-describedby of `._inputNode` + */ + _ariaDescribedNodes: { attribute: false }, + /** + * Based on the role, details of handling model-value-changed repropagation differ. + */ + _repropagationRole: { attribute: false }, + /** + * By default, a field with _repropagationRole 'choice-group' will act as an + * 'endpoint'. This means it will be considered as an individual field: for + * a select, individual options will not be part of the formPath. They + * will. + * Similarly, components that (a11y wise) need to be fieldsets, but 'interaction wise' + * (from Application Developer perspective) need to be more like fields + * (think of an amount-input with a currency select box next to it), can set this + * to true to hide private internals in the formPath. + */ + _isRepropagationEndpoint: { attribute: false }, + }; + } + + /** + * @return {string} + */ + get label() { + return this.__label || (this._labelNode && this._labelNode.textContent) || ''; + } + + /** + * @param {string} newValue + */ + set label(newValue) { + const oldValue = this.label; + /** @type {string} */ + this.__label = newValue; + this.requestUpdate('label', oldValue); + } + + /** + * @return {string} + */ + get helpText() { + return this.__helpText || (this._helpTextNode && this._helpTextNode.textContent) || ''; + } + + /** + * @param {string} newValue + */ + set helpText(newValue) { + const oldValue = this.helpText; + /** @type {string} */ + this.__helpText = newValue; + this.requestUpdate('helpText', oldValue); + } + + /** + * @return {string} + */ + get fieldName() { + return this.__fieldName || this.label || this.name; + } + + /** + * @param {string} value + */ + set fieldName(value) { + /** @type {string} */ + this.__fieldName = value; + } + + /** + * @return {SlotsMap} + */ + get slots() { + return { + ...super.slots, + label: () => { + const label = document.createElement('label'); + label.textContent = this.label; + return label; + }, + 'help-text': () => { + const helpText = document.createElement('div'); + helpText.textContent = this.helpText; + return helpText; + }, + }; + } + + /** @param {import('lit-element').PropertyValues } changedProperties */ + updated(changedProperties) { + super.updated(changedProperties); + + if (changedProperties.has('_ariaLabelledNodes')) { + this.__reflectAriaAttr( + 'aria-labelledby', + this._ariaLabelledNodes, + this.__reorderAriaLabelledNodes, + ); } - get label() { - return this.__label || (this._labelNode && this._labelNode.textContent); + if (changedProperties.has('_ariaDescribedNodes')) { + this.__reflectAriaAttr( + 'aria-describedby', + this._ariaDescribedNodes, + this.__reorderAriaDescribedNodes, + ); } - set label(newValue) { - const oldValue = this.label; - this.__label = newValue; - this.requestUpdate('label', oldValue); + if (changedProperties.has('label')) { + this._onLabelChanged({ label: this.label }); } - get helpText() { - return this.__helpText || (this._helpTextNode && this._helpTextNode.textContent); + if (changedProperties.has('helpText')) { + this._onHelpTextChanged({ helpText: this.helpText }); } + } - set helpText(newValue) { - const oldValue = this.helpText; - this.__helpText = newValue; - this.requestUpdate('helpText', oldValue); + get _inputNode() { + return this.__getDirectSlotChild('input'); + } + + get _labelNode() { + return this.__getDirectSlotChild('label'); + } + + get _helpTextNode() { + return this.__getDirectSlotChild('help-text'); + } + + get _feedbackNode() { + return this.__getDirectSlotChild('feedback'); + } + + constructor() { + super(); + /** @type {string} */ + this._inputId = uuid(this.localName); + /** @type {HTMLElement[]} */ + this._ariaLabelledNodes = []; + /** @type {HTMLElement[]} */ + this._ariaDescribedNodes = []; + /** @type {'child' | 'choice-group' | 'fieldset'} */ + this._repropagationRole = 'child'; + this.addEventListener('model-value-changed', this.__repropagateChildrenValues); + } + + connectedCallback() { + super.connectedCallback(); + this._enhanceLightDomClasses(); + this._enhanceLightDomA11y(); + this._triggerInitialModelValueChangedEvent(); + } + + _triggerInitialModelValueChangedEvent() { + this.__dispatchInitialModelValueChangedEvent(); + } + + _enhanceLightDomClasses() { + if (this._inputNode) { + this._inputNode.classList.add('form-control'); } + } - set fieldName(value) { - this.__fieldName = value; + _enhanceLightDomA11y() { + const { _inputNode, _labelNode, _helpTextNode, _feedbackNode } = this; + + if (_inputNode) { + _inputNode.id = _inputNode.id || this._inputId; } - - get fieldName() { - return this.__fieldName || this.label || this.name; + if (_labelNode) { + _labelNode.setAttribute('for', this._inputId); + this.addToAriaLabelledBy(_labelNode, { idPrefix: 'label' }); } - - get slots() { - return { - ...super.slots, - label: () => { - const label = document.createElement('label'); - label.textContent = this.label; - return label; - }, - 'help-text': () => { - const helpText = document.createElement('div'); - helpText.textContent = this.helpText; - return helpText; - }, - }; + if (_helpTextNode) { + this.addToAriaDescribedBy(_helpTextNode, { idPrefix: 'help-text' }); } - - updated(changedProperties) { - super.updated(changedProperties); - - if (changedProperties.has('_ariaLabelledNodes')) { - this.__reflectAriaAttr( - 'aria-labelledby', - this._ariaLabelledNodes, - this.__reorderAriaLabelledNodes, - ); - } - - if (changedProperties.has('_ariaDescribedNodes')) { - this.__reflectAriaAttr( - 'aria-describedby', - this._ariaDescribedNodes, - this.__reorderAriaDescribedNodes, - ); - } - - if (changedProperties.has('label')) { - this._onLabelChanged({ label: this.label }); - } - - if (changedProperties.has('helpText')) { - this._onHelpTextChanged({ helpText: this.helpText }); - } + if (_feedbackNode) { + _feedbackNode.setAttribute('aria-live', 'polite'); + this.addToAriaDescribedBy(_feedbackNode, { idPrefix: 'feedback' }); } + this._enhanceLightDomA11yForAdditionalSlots(); + } - get _inputNode() { - return this.__getDirectSlotChild('input'); - } - - get _labelNode() { - return this.__getDirectSlotChild('label'); - } - - get _helpTextNode() { - return this.__getDirectSlotChild('help-text'); - } - - get _feedbackNode() { - return this.__getDirectSlotChild('feedback'); - } - - constructor() { - super(); - this._inputId = uuid(this.localName); - this._ariaLabelledNodes = []; - this._ariaDescribedNodes = []; - this._repropagationRole = 'child'; - this.addEventListener('model-value-changed', this.__repropagateChildrenValues); - } - - connectedCallback() { - super.connectedCallback(); - this._enhanceLightDomClasses(); - this._enhanceLightDomA11y(); - this._triggerInitialModelValueChangedEvent(); - } - - _triggerInitialModelValueChangedEvent() { - this.__dispatchInitialModelValueChangedEvent(); - } - - _enhanceLightDomClasses() { - if (this._inputNode) { - this._inputNode.classList.add('form-control'); - } - } - - _enhanceLightDomA11y() { - const { _inputNode, _labelNode, _helpTextNode, _feedbackNode } = this; - - if (_inputNode) { - _inputNode.id = _inputNode.id || this._inputId; - } - if (_labelNode) { - _labelNode.setAttribute('for', this._inputId); - this.addToAriaLabelledBy(_labelNode, { idPrefix: 'label' }); - } - if (_helpTextNode) { - this.addToAriaDescribedBy(_helpTextNode, { idPrefix: 'help-text' }); - } - if (_feedbackNode) { - _feedbackNode.setAttribute('aria-live', 'polite'); - this.addToAriaDescribedBy(_feedbackNode, { idPrefix: 'feedback' }); - } - this._enhanceLightDomA11yForAdditionalSlots(); - } - - /** - * Enhances additional slots(prefix, suffix, before, after) defined by developer. - * - * When boolean attribute data-label or data-description is found, - * the slot element will be connected to the input via aria-labelledby or aria-describedby - */ - _enhanceLightDomA11yForAdditionalSlots( - additionalSlots = ['prefix', 'suffix', 'before', 'after'], - ) { - additionalSlots.forEach(additionalSlot => { - const element = this.__getDirectSlotChild(additionalSlot); - if (element) { - if (element.hasAttribute('data-label') === true) { - this.addToAriaLabelledBy(element, { idPrefix: additionalSlot }); - } - if (element.hasAttribute('data-description') === true) { - this.addToAriaDescribedBy(element, { idPrefix: additionalSlot }); - } + /** + * Enhances additional slots(prefix, suffix, before, after) defined by developer. + * + * When boolean attribute data-label or data-description is found, + * the slot element will be connected to the input via aria-labelledby or aria-describedby + * @param {string[]} additionalSlots + */ + _enhanceLightDomA11yForAdditionalSlots( + additionalSlots = ['prefix', 'suffix', 'before', 'after'], + ) { + additionalSlots.forEach(additionalSlot => { + const element = this.__getDirectSlotChild(additionalSlot); + if (element) { + if (element.hasAttribute('data-label') === true) { + this.addToAriaLabelledBy(element, { idPrefix: additionalSlot }); } - }); - } - - /** - * Will handle help text, validation feedback and character counter, - * prefix/suffix/before/after (if they contain data-description flag attr). - * Also, contents of id references that will be put in the ._ariaDescribedby property - * from an external context, will be read by a screen reader. - */ - __reflectAriaAttr(attrName, nodes, reorder) { - if (this._inputNode) { - if (reorder) { - const insideNodes = nodes.filter(n => this.contains(n)); - const outsideNodes = nodes.filter(n => !this.contains(n)); - // eslint-disable-next-line no-param-reassign - nodes = [...getAriaElementsInRightDomOrder(insideNodes), ...outsideNodes]; + if (element.hasAttribute('data-description') === true) { + this.addToAriaDescribedBy(element, { idPrefix: additionalSlot }); } - const string = nodes.map(n => n.id).join(' '); - this._inputNode.setAttribute(attrName, string); } - } + }); + } - _onLabelChanged({ label }) { - if (this._labelNode) { - this._labelNode.textContent = label; + /** + * Will handle help text, validation feedback and character counter, + * prefix/suffix/before/after (if they contain data-description flag attr). + * Also, contents of id references that will be put in the ._ariaDescribedby property + * from an external context, will be read by a screen reader. + * @param {string} attrName + * @param {HTMLElement[]} nodes + * @param {boolean|undefined} reorder + */ + __reflectAriaAttr(attrName, nodes, reorder) { + if (this._inputNode) { + if (reorder) { + const insideNodes = nodes.filter(n => this.contains(n)); + const outsideNodes = nodes.filter(n => !this.contains(n)); + // eslint-disable-next-line no-param-reassign + nodes = [...getAriaElementsInRightDomOrder(insideNodes), ...outsideNodes]; } + const string = nodes.map(n => n.id).join(' '); + this._inputNode.setAttribute(attrName, string); } + } - _onHelpTextChanged({ helpText }) { - if (this._helpTextNode) { - this._helpTextNode.textContent = helpText; - } + /** + * + * @param {{label:string}} opts + */ + _onLabelChanged({ label }) { + if (this._labelNode) { + this._labelNode.textContent = label; } + } - /** - * Default Render Result: - *
- *
- * - *
- * - * - * - *
- *
- *
- *
- * - *
- *
- *
- * - *
- *
- * - *
- *
- * - *
- *
- *
- * - *
- *
- * - *
- */ - render() { - return html` -
- ${this._groupOneTemplate()} + /** + * + * @param {{helpText:string}} opts + */ + _onHelpTextChanged({ helpText }) { + if (this._helpTextNode) { + this._helpTextNode.textContent = helpText; + } + } + + /** + * Default Render Result: + *
+ *
+ * + *
+ * + * + * + *
+ *
+ *
+ *
+ * + *
+ *
+ *
+ * + *
+ *
+ * + *
+ *
+ * + *
+ *
+ *
+ * + *
+ *
+ * + *
+ */ + render() { + return html` +
+ ${this._groupOneTemplate()} +
+
+ ${this._groupTwoTemplate()} +
+ `; + } + + /** + * @return {TemplateResult} + */ + _groupOneTemplate() { + return html` ${this._labelTemplate()} ${this._helpTextTemplate()} `; + } + + /** + * @return {TemplateResult} + */ + _groupTwoTemplate() { + return html` ${this._inputGroupTemplate()} ${this._feedbackTemplate()} `; + } + + /** + * @return {TemplateResult} + */ + // eslint-disable-next-line class-methods-use-this + _labelTemplate() { + return html` +
+ +
+ `; + } + + /** + * @return {TemplateResult} + */ + // eslint-disable-next-line class-methods-use-this + _helpTextTemplate() { + return html` + + + + `; + } + + /** + * @return {TemplateResult} + */ + _inputGroupTemplate() { + return html` +
+ ${this._inputGroupBeforeTemplate()} +
+ ${this._inputGroupPrefixTemplate()} ${this._inputGroupInputTemplate()} + ${this._inputGroupSuffixTemplate()}
-
- ${this._groupTwoTemplate()} -
- `; - } + ${this._inputGroupAfterTemplate()} +
+ `; + } - _groupOneTemplate() { - return html` ${this._labelTemplate()} ${this._helpTextTemplate()} `; - } + /** + * @return {TemplateResult} + */ + // eslint-disable-next-line class-methods-use-this + _inputGroupBeforeTemplate() { + return html` +
+ +
+ `; + } - _groupTwoTemplate() { - return html` ${this._inputGroupTemplate()} ${this._feedbackTemplate()} `; - } - - // eslint-disable-next-line class-methods-use-this - _labelTemplate() { - return html` -
- -
- `; - } - - // eslint-disable-next-line class-methods-use-this - _helpTextTemplate() { - return html` - - - - `; - } - - _inputGroupTemplate() { - return html` -
- ${this._inputGroupBeforeTemplate()} -
- ${this._inputGroupPrefixTemplate()} ${this._inputGroupInputTemplate()} - ${this._inputGroupSuffixTemplate()} + /** + * @return {TemplateResult | nothing} + */ + _inputGroupPrefixTemplate() { + return !Array.from(this.children).find(child => child.slot === 'prefix') + ? nothing + : html` +
+
- ${this._inputGroupAfterTemplate()} -
- `; + `; + } + + /** + * @return {TemplateResult} + */ + // eslint-disable-next-line class-methods-use-this + _inputGroupInputTemplate() { + return html` +
+ +
+ `; + } + + /** + * @return {TemplateResult | nothing} + */ + _inputGroupSuffixTemplate() { + return !Array.from(this.children).find(child => child.slot === 'suffix') + ? nothing + : html` +
+ +
+ `; + } + + /** + * @return {TemplateResult} + */ + // eslint-disable-next-line class-methods-use-this + _inputGroupAfterTemplate() { + return html` +
+ +
+ `; + } + + /** + * @return {TemplateResult} + */ + // eslint-disable-next-line class-methods-use-this + _feedbackTemplate() { + return html` + + `; + } + + /** + * @param {?} modelValue + * @return {boolean} + */ + _isEmpty(modelValue = this.modelValue) { + let value = modelValue; + if (this.modelValue instanceof Unparseable) { + value = this.modelValue.viewValue; } - // eslint-disable-next-line class-methods-use-this - _inputGroupBeforeTemplate() { - return html` -
- -
- `; + // Checks for empty platform types: Objects, Arrays, Dates + if (typeof value === 'object' && value !== null && !(value instanceof Date)) { + return !Object.keys(value).length; } - _inputGroupPrefixTemplate() { - return !Array.from(this.children).find(child => child.slot === 'prefix') - ? nothing - : html` -
- -
- `; + // eslint-disable-next-line no-mixed-operators + // Checks for empty platform types: Numbers, Booleans + const isNumberValue = typeof value === 'number' && (value === 0 || Number.isNaN(value)); + const isBooleanValue = typeof value === 'boolean' && value === false; + + return !value && !isNumberValue && !isBooleanValue; + } + + /** + * All CSS below is written from a generic mindset, following BEM conventions: + * https://en.bem.info/methodology/ + * Although the CSS and HTML are implemented by the component, they should be regarded as + * totally decoupled. + * + * Not only does this force us to write better structured css, it also allows for future + * reusability in many different ways like: + * - disabling shadow DOM for a component (for water proof encapsulation can be combined with + * a build step) + * - easier translation to more flexible, WebComponents agnostic solutions like JSS + * (allowing extends, mixins, reasoning, IDE integration, tree shaking etc.) + * - export to a CSS module for reuse in an outer context + * + * + * Please note that the HTML structure is purposely 'loose', allowing multiple design systems + * to be compatible + * with the CSS component. + * Note that every occurence of '::slotted(*)' can be rewritten to '> *' for use in an other + * context + */ + + /** + * {block} .form-field + * + * Structure: + * - {element} .form-field__label : a wrapper element around the projected label + * - {element} .form-field__help-text (optional) : a wrapper element around the projected + * help-text + * - {block} .input-group : a container around the input element, including prefixes and + * suffixes + * - {element} .form-field__feedback (optional) : a wrapper element around the projected + * (validation) feedback message + * + * Modifiers: + * - {state} [disabled] when .form-control (,