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'}>${tag}>`)
+ );
+
+ 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}>${tag}>`,
+ )
+ );
+
+ 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'}>${tag}>`)
+ );
+
+ 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;
+ }}
+ >
+ ${tag}>
+ `)
+ );
+ 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;
+ }}
+ >
+ ${tag}>
+ `)
+ );
+ 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'}>${tag}>`)
+ );
+
+ 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'}>${tag}>`)
+ );
+
+ 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}>${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}>${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';