diff --git a/.changeset/weak-bottles-accept.md b/.changeset/weak-bottles-accept.md new file mode 100644 index 0000000000..22c41d5855 --- /dev/null +++ b/.changeset/weak-bottles-accept.md @@ -0,0 +1,5 @@ +--- +'@lion/ui': minor +--- + +Add CustomChoiceGroupMixin and LionRadioWithUserValue diff --git a/docs/components/radio-group/use-cases.md b/docs/components/radio-group/use-cases.md index 732320ada4..52264d50aa 100644 --- a/docs/components/radio-group/use-cases.md +++ b/docs/components/radio-group/use-cases.md @@ -4,6 +4,9 @@ import { html } from '@mdjs/mdjs-preview'; import '@lion/ui/define/lion-radio-group.js'; import '@lion/ui/define/lion-radio.js'; +import '@lion/ui/define/lion-radio-with-user-value.js'; +import { LionRadioWithUserValue } from '@lion/ui/radio-group.js'; +import { LionCollapsible } from '@lion/ui/collapsible.js'; ``` ## Model value @@ -138,3 +141,68 @@ export const event = ({ shadowRoot }) => html` Selected dinosaur: N/A `; ``` + +## Option by user input + +You can add an option where user can set the value. + +```js preview-story +export const userValue = ({ shadowRoot }) => html` + + (ev.target.parentElement.querySelector('#selectedDinosaurWithUserValue').innerText = + ev.target.modelValue)} + > + + + + + +
+ Selected dinosaur: N/A +`; +``` + +The `user-input` can observe whether it is selected or not by using the `createMutationObserver` class method. + +```js preview-story +export const userValueObservingOption = ({ shadowRoot }) => { + class CollapsibleUserInput extends LionCollapsible { + connectedCallback() { + super.connectedCallback(); + const observer = LionRadioWithUserValue.createMutationObserver(mutation => { + if (mutation.target.dataset.checked === 'true') { + this.opened = true; + } else { + this.opened = false; + } + }); + observer.observe(this.shadowRoot?.host, { attributes: true }); + } + } + customElements.define('collapsible-user-input', CollapsibleUserInput); + + return html` + + (ev.target.parentElement.querySelector('#selectedDinosaurWithUserValue2').innerText = + ev.target.modelValue)} + > + + + + + + + + + +
+ Selected dinosaur: N/A + `; +}; +``` diff --git a/packages/ui/components/form-core/src/choice-group/ChoiceInputMixin.js b/packages/ui/components/form-core/src/choice-group/ChoiceInputMixin.js index 4a673476a2..1ecc25907b 100644 --- a/packages/ui/components/form-core/src/choice-group/ChoiceInputMixin.js +++ b/packages/ui/components/form-core/src/choice-group/ChoiceInputMixin.js @@ -175,6 +175,7 @@ const ChoiceInputMixinImplementation = superclass =>
+ ${this._afterLabel()} @@ -189,6 +190,13 @@ const ChoiceInputMixinImplementation = superclass => return nothing; } + /** + * @protected + */ + _afterLabel() { + return nothing; + } + /** * @protected */ diff --git a/packages/ui/components/form-core/src/choice-group/CustomChoiceInputMixin.js b/packages/ui/components/form-core/src/choice-group/CustomChoiceInputMixin.js new file mode 100644 index 0000000000..108bfd2b62 --- /dev/null +++ b/packages/ui/components/form-core/src/choice-group/CustomChoiceInputMixin.js @@ -0,0 +1,191 @@ +import { dedupeMixin } from '@open-wc/dedupe-mixin'; +import { html } from 'lit'; +import { ChoiceInputMixin } from './ChoiceInputMixin.js'; +import { LionInput } from '../../../input/src/LionInput.js'; + +/** + * @typedef {import('../FormControlMixin.js').HTMLElementWithValue} HTMLElementWithValue + */ + +/** + * @param {import('@open-wc/dedupe-mixin').Constructor} superclass + */ +const CustomChoiceInputMixinImplementation = superclass => + class CustomChoiceInputMixin extends ChoiceInputMixin(superclass) { + /** @type {(mutationHandler: (mutation: MutationRecord) => void) => MutationObserver} */ + static createMutationObserver = mutationHandler => + new MutationObserver(mutations => { + const mutation = mutations.find( + ({ type, attributeName }) => type === 'attributes' && attributeName === 'data-checked', + ); + + if (mutation) { + mutationHandler(mutation); + } + }); + + get slots() { + return { + ...super.slots, + 'user-input': () => { + const native = document.createElement('input'); + native.setAttribute('value', this.choiceValue); + + return native; + }, + }; + } + + connectedCallback() { + super.connectedCallback(); + + this.shadowRoot?.addEventListener('slotchange', e => { + if (/** @type {{ target: HTMLSlotElement | null }} */ (e).target?.name === 'user-input') { + this.__syncChoiceValueToUserValue(); + this._listenToUserInput(); + if (this._userInputSlotNode) { + /** @type {HTMLElement} */ (this._userInputSlotNode).dataset.checked = + this.checked.toString(); + } + } + }); + } + + /** + * @override + */ + // eslint-disable-next-line class-methods-use-this + _afterLabel() { + return html``; + } + + /** + * @param {string} [name] + * @param {unknown} [oldValue] + * @param {import('lit').PropertyDeclaration} [options] + * @returns {void} + */ + requestUpdate(name, oldValue, options) { + super.requestUpdate(name, oldValue, options); + + if (name === 'checked') { + if (this.checked) { + this._focusToUserInput(); + } + if (this._userInputSlotNode) { + /** @type {HTMLElement} */ (this._userInputSlotNode).dataset.checked = + this.checked.toString(); + } + } + } + + get _userInputNode() { + if (this.__userInputNode === null) { + this._updateUserInput(); + } + + return this.__userInputNode; + } + + _updateUserInput() { + const slot = this._userInputSlotNode; + if (!slot) { + this.__userInputType = null; + this.__userInputNode = null; + return; + } + + const nodeIterator = document.createNodeIterator(slot, NodeFilter.SHOW_ELEMENT); + + let currentNode = null; + while (currentNode === null) { + currentNode = nodeIterator.nextNode(); + if (currentNode instanceof LionInput) { + this.__userInputType = 'lioninput'; + this.__userInputNode = currentNode; + return; + } + } + + if (slot?.tagName === 'INPUT') { + this.__userInputType = 'native'; + this.__userInputNode = /** @type {HTMLInputElement} */ (slot); + return; + } + + const inputInsideSlot = slot?.querySelector('input'); + if (inputInsideSlot) { + this.__userInputType = 'native'; + } + + this.__userInputNode = inputInsideSlot; + } + + _getUserValue() { + if (this.__userInputType === 'lioninput') { + return /** @type { LionInput } */ (this._userInputNode).modelValue; + } + + if (this.__userInputType === 'native') { + return /** @type { HTMLElementWithValue } */ (this._userInputNode).value; + } + + return undefined; + } + + _listenToUserInput() { + this._listenToUserValue(); + this._listenToUserInputFocus(); + } + + _listenToUserValue() { + this._userInputNode?.addEventListener('input', this.__syncUserValueToChoiceValue.bind(this)); + } + + _listenToUserInputFocus() { + this._userInputNode?.addEventListener('focus', () => { + this.checked = true; + }); + } + + _focusToUserInput() { + this._userInputNode?.focus(); + } + + get _userInputSlotNode() { + return /** @type {HTMLSlotElement | null} */ ( + this.shadowRoot?.querySelector('slot[name="user-input"]') + )?.assignedElements()?.[0]; + } + + __syncUserValueToChoiceValue() { + const userValue = this._getUserValue(); + + if (userValue !== undefined) { + this._isHandlingUserInput = true; + this.choiceValue = userValue; + this._isHandlingUserInput = false; + } + } + + __syncChoiceValueToUserValue() { + if (this.__userInputType === null) { + this._updateUserInput(); + } + if (this.__userInputType === 'lioninput') { + // @ts-ignore -- because the _userInputNode is guaranteed to be LionInput when __userInputType is lioninput + this._userInputNode.modelValue = this.choiceValue; + } else if (this.__userInputType === 'native') { + // @ts-ignore -- because the _userInputNode is guaranteed to be input when __userInputType is native + this._userInputNode.setAttribute('value', this.choiceValue); + } + } + + /** @type {'native' | 'lioninput' | null} */ + __userInputType = null; + + /** @type { HTMLElementWithValue | HTMLInputElement | HTMLTextAreaElement | LionInput | null } */ + __userInputNode = null; + }; + +export const CustomChoiceInputMixin = dedupeMixin(CustomChoiceInputMixinImplementation); diff --git a/packages/ui/components/form-core/test-suites/choice-group/CustomChoiceInputMixin.suite.js b/packages/ui/components/form-core/test-suites/choice-group/CustomChoiceInputMixin.suite.js new file mode 100644 index 0000000000..e6366882f5 --- /dev/null +++ b/packages/ui/components/form-core/test-suites/choice-group/CustomChoiceInputMixin.suite.js @@ -0,0 +1,176 @@ +import { LionInput } from '@lion/ui/input.js'; +import '@lion/ui/define/lion-input-date.js'; +import '@lion/ui/define/lion-input.js'; +import { expect, fixture, html, unsafeStatic } from '@open-wc/testing'; + +import sinon from 'sinon'; +import { CustomChoiceInputMixin } from '../../src/choice-group/CustomChoiceInputMixin.js'; +import { getInputMembers } from '../../../input/test-helpers/getInputMembers.js'; + +class ChoiceInput extends CustomChoiceInputMixin(LionInput) { + constructor() { + super(); + this.type = 'radio'; + } +} +customElements.define('choice-group-user-input', ChoiceInput); + +const getCustomChoiceInputMembers = (/** @type {ChoiceInput} */ el) => ({ + // @ts-ignore + ...getInputMembers(/** @type {LionInput} */ (el)), + // @ts-ignore + _userInputNode: /** @type { HTMLInputElement | LionInput } */ (el._userInputNode), + // @ts-ignore + _userInputSlotNode: /** @type { HTMLElement } */ (el._userInputSlotNode), +}); + +/** + * @param {{ tagString?:string, tagType?: string}} config + * @deprecated + */ +export function runCustomChoiceInputMixinSuite({ tagString } = {}) { + const cfg = { + tagString: tagString || 'choice-group-input', + }; + + const tag = unsafeStatic(cfg.tagString); + describe(`CustomChoiceInputMixin: ${tagString}`, () => { + it('syncs choiceValue to user value', async () => { + const el = /** @type {ChoiceInput} */ ( + await fixture(html`<${tag} .choiceValue=${'foo'}>`) + ); + + const userValue = getCustomChoiceInputMembers(el)._userInputNode.value; + expect(userValue).to.equal('foo'); + }); + + it('syncs complex data to user value', async () => { + const date = new Date(2018, 11, 24, 10, 33, 30, 0); + + const el = /** @type {ChoiceInput} */ ( + await fixture( + html`<${tag} .choiceValue=${date}>`, + ) + ); + + const userValue = /** @type {LionInput} */ (getCustomChoiceInputMembers(el)._userInputNode) + .modelValue; + expect(userValue.valueOf()).to.equal(date.valueOf()); + }); + + it('syncs user value to choiceValue', async () => { + const el = /** @type {ChoiceInput} */ ( + await fixture(html`<${tag} .choiceValue=${'foo'}>`) + ); + + const userInput = getCustomChoiceInputMembers(el)._userInputNode; + userInput.value = 'bar'; + userInput?.dispatchEvent(new Event('input', { bubbles: true })); + + expect(el.choiceValue).to.equal('bar'); + }); + + it('fires one "model-value-changed" event if user value has changed', async () => { + let counter = 0; + const el = /** @type {ChoiceInput} */ ( + await fixture(html` + <${tag} + @model-value-changed=${() => { + counter += 1; + }} + > + + `) + ); + expect(counter).to.equal(0); + + el.checked = true; + expect(counter).to.equal(1); + + const userInput = getCustomChoiceInputMembers(el)._userInputNode; + userInput.value = 'bar'; + userInput.dispatchEvent(new Event('input', { bubbles: true })); + + expect(counter).to.equal(2); + }); + + it('adds "isTriggerByUser" flag on model-value-changed', async () => { + let isTriggeredByUser; + const el = /** @type {ChoiceInput} */ ( + await fixture(html` + <${tag} + @model-value-changed=${(/** @type {CustomEvent} */ event) => { + isTriggeredByUser = event.detail.isTriggeredByUser; + }} + > + + `) + ); + el.checked = true; + + const userInput = getCustomChoiceInputMembers(el)._userInputNode; + userInput.value = 'bar'; + userInput.dispatchEvent(new Event('input', { bubbles: true })); + + expect(isTriggeredByUser).to.be.true; + }); + + it('focuses user input when choice is checked', async () => { + const el = /** @type {ChoiceInput} */ ( + await fixture(html`<${tag} .choiceValue=${'foo'}>`) + ); + + getCustomChoiceInputMembers(el)._inputNode.click(); + + const userInput = getCustomChoiceInputMembers(el)._userInputNode; + expect(document.activeElement === userInput).to.be.true; + }); + + it('checks choice when user input is focused', async () => { + const el = /** @type {ChoiceInput} */ ( + await fixture(html`<${tag} .choiceValue=${'foo'}>`) + ); + + expect(el.checked).to.be.false; + + const userInput = getCustomChoiceInputMembers(el)._userInputNode; + userInput?.focus(); + + expect(el.checked).to.be.true; + }); + + it('update data-checked attribute of the user-input slot according to the checked state', async () => { + const el = /** @type {ChoiceInput} */ (await fixture(html`<${tag}>`)); + + const userInputSlot = getCustomChoiceInputMembers(el)._userInputSlotNode; + expect(userInputSlot.dataset.checked).to.equal('false'); + + el.checked = true; + expect(userInputSlot.dataset.checked).to.equal('true'); + }); + + it('can be observed for the data-checked attribute mutation', async () => { + const spy = sinon.spy(); + class CheckedAwareUserInput extends LionInput { + connectedCallback() { + super.connectedCallback(); + const observer = ChoiceInput.createMutationObserver(spy); + observer.observe(/** @type {ShadowRoot} */ (this.shadowRoot).host, { attributes: true }); + } + } + customElements.define('checked-aware-user-input', CheckedAwareUserInput); + + const el = /** @type {ChoiceInput} */ ( + await fixture(html`<${tag}>`) + ); + + el.checked = true; + + const userInputSlot = getCustomChoiceInputMembers(el)._userInputSlotNode; + expect(userInputSlot.dataset.checked).to.equal('true'); + + expect(spy).to.have.been.calledOnce; + expect(spy.lastCall.args[0].target.dataset.checked).to.equal('true'); + }); + }); +} diff --git a/packages/ui/components/form-core/types/choice-group/ChoiceInputMixinTypes.ts b/packages/ui/components/form-core/types/choice-group/ChoiceInputMixinTypes.ts index 5738c9b8fb..ebe64a529a 100644 --- a/packages/ui/components/form-core/types/choice-group/ChoiceInputMixinTypes.ts +++ b/packages/ui/components/form-core/types/choice-group/ChoiceInputMixinTypes.ts @@ -1,6 +1,7 @@ import { Constructor } from '@open-wc/dedupe-mixin'; import { LitElement, TemplateResult, CSSResultArray } from 'lit'; import { FormatHost } from '../FormatMixinTypes.js'; +import { SlotHost } from '../../../core/types/SlotMixinTypes.js'; export interface ChoiceInputModelValue { checked: boolean; @@ -36,6 +37,7 @@ export declare class ChoiceInputHost { protected requestUpdate(name: string, oldValue: any): void; protected _choiceGraphicTemplate(): TemplateResult; protected _afterTemplate(): TemplateResult; + protected _afterLabel(): TemplateResult; protected _preventDuplicateLabelClick(ev: Event): void; protected _syncNameToParentFormGroup(): void; protected _toggleChecked(ev: Event): void; @@ -56,6 +58,7 @@ export declare function ChoiceInputImplementation & Pick & + Constructor & Constructor & Pick & Pick; diff --git a/packages/ui/components/radio-group/src/LionRadioWithUserValue.js b/packages/ui/components/radio-group/src/LionRadioWithUserValue.js new file mode 100644 index 0000000000..c202dcdace --- /dev/null +++ b/packages/ui/components/radio-group/src/LionRadioWithUserValue.js @@ -0,0 +1,33 @@ +import { LionInput } from '@lion/ui/input.js'; +import { CustomChoiceInputMixin } from '../../form-core/src/choice-group/CustomChoiceInputMixin.js'; + +/** + * Lion-radio-with-user-value can be used inside a lion-radio-group. + * + * + * + * + * + * + * + * + * + * + * + *
+ * + *
+ *
+ * + * You can preselect an option by setting marking an lion-radio checked + * Example: + * + * + * @customElement lion-radio-with-user-value + */ +export class LionRadioWithUserValue extends CustomChoiceInputMixin(LionInput) { + connectedCallback() { + super.connectedCallback(); + this.type = 'radio'; + } +} diff --git a/packages/ui/components/radio-group/test/lion-radio-with-user-value-integration.test.js b/packages/ui/components/radio-group/test/lion-radio-with-user-value-integration.test.js new file mode 100644 index 0000000000..90a96bb1bb --- /dev/null +++ b/packages/ui/components/radio-group/test/lion-radio-with-user-value-integration.test.js @@ -0,0 +1,8 @@ +import '@lion/ui/define/lion-radio-with-user-value.js'; +import { + runChoiceInputMixinSuite, + runCustomChoiceInputMixinSuite, +} from '@lion/ui/form-core-test-suites.js'; + +runChoiceInputMixinSuite({ tagString: 'lion-radio-with-user-value' }); +runCustomChoiceInputMixinSuite({ tagString: 'lion-radio-with-user-value' }); diff --git a/packages/ui/exports/define/lion-radio-with-user-value.js b/packages/ui/exports/define/lion-radio-with-user-value.js new file mode 100644 index 0000000000..fa979cdea7 --- /dev/null +++ b/packages/ui/exports/define/lion-radio-with-user-value.js @@ -0,0 +1,3 @@ +import { LionRadioWithUserValue } from '../radio-group.js'; + +customElements.define('lion-radio-with-user-value', LionRadioWithUserValue); diff --git a/packages/ui/exports/form-core-test-suites.js b/packages/ui/exports/form-core-test-suites.js index 6a80c66385..366bf4ab90 100644 --- a/packages/ui/exports/form-core-test-suites.js +++ b/packages/ui/exports/form-core-test-suites.js @@ -1,5 +1,6 @@ export { runChoiceGroupMixinSuite } from '../components/form-core/test-suites/choice-group/ChoiceGroupMixin.suite.js'; export { runChoiceInputMixinSuite } from '../components/form-core/test-suites/choice-group/ChoiceInputMixin.suite.js'; +export { runCustomChoiceInputMixinSuite } from '../components/form-core/test-suites/choice-group/CustomChoiceInputMixin.suite.js'; export { runFormGroupMixinInputSuite } from '../components/form-core/test-suites/form-group/FormGroupMixin-input.suite.js'; export { runFormGroupMixinSuite } from '../components/form-core/test-suites/form-group/FormGroupMixin.suite.js'; export { runFormatMixinSuite } from '../components/form-core/test-suites/FormatMixin.suite.js'; diff --git a/packages/ui/exports/radio-group.js b/packages/ui/exports/radio-group.js index 86457d5efd..b06e7a7459 100644 --- a/packages/ui/exports/radio-group.js +++ b/packages/ui/exports/radio-group.js @@ -1,2 +1,3 @@ export { LionRadioGroup } from '../components/radio-group/src/LionRadioGroup.js'; export { LionRadio } from '../components/radio-group/src/LionRadio.js'; +export { LionRadioWithUserValue } from '../components/radio-group/src/LionRadioWithUserValue.js';