From f6a467e8d0749fe5c4c34d1d3ee5d741e6eac4a5 Mon Sep 17 00:00:00 2001 From: Tommmaso Menga Date: Wed, 30 Oct 2024 17:10:19 +0100 Subject: [PATCH] fix(sbb-radio-button): handle groups with the same name --- .../form-associated-radio-button-mixin.ts | 69 ++- .../common/radio-button-common.spec.ts | 548 ++++++++++-------- .../radio-button-panel/radio-button-panel.ts | 3 +- 3 files changed, 344 insertions(+), 276 deletions(-) diff --git a/src/elements/core/mixins/form-associated-radio-button-mixin.ts b/src/elements/core/mixins/form-associated-radio-button-mixin.ts index 2113db7c91..9578f93d7b 100644 --- a/src/elements/core/mixins/form-associated-radio-button-mixin.ts +++ b/src/elements/core/mixins/form-associated-radio-button-mixin.ts @@ -37,56 +37,74 @@ export declare class SbbFormAssociatedRadioButtonMixinType protected navigateByKeyboard(radio: SbbFormAssociatedRadioButtonMixinType): Promise; } +type RadioButtonGroup = { + name: string; + form: HTMLFormElement | null; + radios: SbbFormAssociatedRadioButtonMixinType[]; +}; + /** - * A static registry that holds a collection of `radio-buttons`, grouped by `name`. + * A static registry that holds a collection of `radio-buttons`, grouped by `name + form`. * It is mainly used to support the standalone groups of radios. + * The identifier of a group is composed of the couple 'name + form' because multiple radios with the same name can coexist (as long as they belong to different forms) * @internal */ export class RadioButtonRegistry { - private static _registry: { [x: string]: SbbFormAssociatedRadioButtonMixinType[] } = {}; + private static _registry: RadioButtonGroup[] = []; private constructor() {} /** - * Adds @radio to the @groupName group. Checks for duplicates + * Adds @radio to the '@groupName + @form' group. Checks for duplicates */ public static addRadioToGroup( radio: SbbFormAssociatedRadioButtonMixinType, groupName: string, + form = radio.form, ): void { - if (!this._registry[groupName]) { - this._registry[groupName] = []; + let group = this._registry.find((g) => g.name === groupName && g.form === form); + + // If it does not exist, initializes it + if (!group) { + group = { name: groupName, form: form, radios: [] }; + this._registry.push(group); } + // Check for duplicates - if (this._registry[groupName].indexOf(radio) !== -1) { + if (group.radios.indexOf(radio) !== -1) { return; } - this._registry[groupName].push(radio); + group.radios.push(radio); } /** - * Removes @radio from the @groupName group. + * Removes @radio from the group it belongs. */ - public static removeRadioFromGroup( - radio: SbbFormAssociatedRadioButtonMixinType, - groupName: string, - ): void { - const index = this._registry[groupName]?.indexOf(radio); - if (!this._registry[groupName] || index === -1) { + public static removeRadioFromGroup(radio: SbbFormAssociatedRadioButtonMixinType): void { + // Find the group where @radio belongs + const groupIndex = this._registry.findIndex((g) => g.radios.find((r) => r === radio)); + const group = this._registry[groupIndex]; + if (!group) { return; } - this._registry[groupName].splice(index, 1); - if (this._registry[groupName].length === 0) { - delete this._registry[groupName]; + // Remove @radio from the group + group.radios.splice(group.radios.indexOf(radio), 1); + + // If the group is empty, clear it + if (group.radios.length === 0) { + this._registry.splice(groupIndex, 1); } } /** - * Return an array of radios that belong to @groupName + * Return an array of radios that belong to the group '@groupName + @form' */ - public static getRadios(groupName: string): SbbFormAssociatedRadioButtonMixinType[] { - return this._registry[groupName] ?? []; + public static getRadios( + groupName: string, + form: HTMLFormElement | null, + ): SbbFormAssociatedRadioButtonMixinType[] { + return this._registry.find((g) => g.name === groupName && g.form === form)?.radios ?? []; } } @@ -158,8 +176,7 @@ export const SbbFormAssociatedRadioButtonMixin = r !== (this as unknown as SbbFormAssociatedRadioButtonMixinType)) .forEach((r) => (r.checked = false)); } diff --git a/src/elements/radio-button/common/radio-button-common.spec.ts b/src/elements/radio-button/common/radio-button-common.spec.ts index 126cd7c4c0..416a2aa232 100644 --- a/src/elements/radio-button/common/radio-button-common.spec.ts +++ b/src/elements/radio-button/common/radio-button-common.spec.ts @@ -219,312 +219,366 @@ describe(`radio-button common behaviors`, () => { describe(selector, () => { const tagSingle = unsafeStatic(selector); - beforeEach(async () => { - form = await fixture(html` -
-
- <${tagSingle} value="1" name="sbb-group-1">1 - <${tagSingle} value="2" name="sbb-group-1">2 - <${tagSingle} value="3" name="sbb-group-1">3 - - <${tagSingle} value="4" name="sbb-group-2">1 - <${tagSingle} value="5" name="sbb-group-2">2 -
-
- - - - - - -
-
`); - - elements = Array.from(form.querySelectorAll(selector)); - nativeElements = Array.from(form.querySelectorAll('input')); - fieldset = form.querySelector('#sbb-set')!; - nativeFieldset = form.querySelector('#native-set')!; - - inputSpy = new EventSpy('input', fieldset); - changeSpy = new EventSpy('change', fieldset); - nativeInputSpy = new EventSpy('input', nativeFieldset); - nativeChangeSpy = new EventSpy('change', nativeFieldset); - - await waitForLitRender(form); - }); - - it('should find connected form', () => { - expect(elements[0].form).to.be.equal(form); - }); - - it('first elements of groups should be focusable', async () => { - expect(elements[0].tabIndex).to.be.equal(0); - expect(elements[1].tabIndex).to.be.equal(-1); - expect(elements[2].tabIndex).to.be.equal(-1); - expect(elements[3].tabIndex).to.be.equal(0); - expect(elements[4].tabIndex).to.be.equal(-1); - await compareToNative(); - }); - - it('should select on click', async () => { - elements[1].click(); - await waitForLitRender(form); - expect(document.activeElement === elements[1]).to.be.true; - - nativeElements[1].click(); - await waitForLitRender(form); - - expect(elements[0].tabIndex).to.be.equal(-1); - expect(elements[1].tabIndex).to.be.equal(0); - expect(elements[1].checked).to.be.true; - await compareToNative(); - }); - - it('should reflect state after programmatic change', async () => { - elements[1].checked = true; - nativeElements[1].checked = true; - await waitForLitRender(form); - - expect(elements[0].tabIndex).to.be.equal(-1); - expect(elements[1].tabIndex).to.be.equal(0); - await compareToNative(); - }); - - it('should reset on form reset', async () => { - elements[1].checked = true; - nativeElements[1].checked = true; - await waitForLitRender(form); - - form.reset(); - await waitForLitRender(form); + describe('general behavior', () => { + beforeEach(async () => { + form = await fixture(html` +
+
+ <${tagSingle} value="1" name="sbb-group-1">1 + <${tagSingle} value="2" name="sbb-group-1">2 + <${tagSingle} value="3" name="sbb-group-1">3 + + <${tagSingle} value="4" name="sbb-group-2">1 + <${tagSingle} value="5" name="sbb-group-2">2 +
+
+ + + + + + +
+
`); + + elements = Array.from(form.querySelectorAll(selector)); + nativeElements = Array.from(form.querySelectorAll('input')); + fieldset = form.querySelector('#sbb-set')!; + nativeFieldset = form.querySelector('#native-set')!; + + inputSpy = new EventSpy('input', fieldset); + changeSpy = new EventSpy('change', fieldset); + nativeInputSpy = new EventSpy('input', nativeFieldset); + nativeChangeSpy = new EventSpy('change', nativeFieldset); - expect(elements[0].tabIndex).to.be.equal(0); - expect(elements[1].tabIndex).to.be.equal(-1); - expect(elements[1].checked).to.be.false; - await compareToNative(); - }); - - it('should restore default on form reset', async () => { - elements[1].toggleAttribute('checked', true); - nativeElements[1].toggleAttribute('checked', true); - await waitForLitRender(form); - - elements[0].click(); - nativeElements[0].click(); - await waitForLitRender(form); - - form.reset(); - await waitForLitRender(form); - - expect(elements[0].tabIndex).to.be.equal(-1); - expect(elements[1].tabIndex).to.be.equal(0); - expect(elements[0].checked).to.be.false; - expect(elements[1].checked).to.be.true; - await compareToNative(); - }); - - it('should restore on form restore', async () => { - // Mimic tab restoration. Does not test the full cycle as we can not set the browser in the required state. - elements[0].formStateRestoreCallback('2', 'restore'); - elements[1].formStateRestoreCallback('2', 'restore'); - await waitForLitRender(form); - - expect(elements[0].checked).to.be.false; - expect(elements[1].checked).to.be.true; - expect(elements[0].tabIndex).to.be.equal(-1); - expect(elements[1].tabIndex).to.be.equal(0); - }); - - it('should handle adding a new radio to the group', async () => { - elements[0].checked = true; - await waitForLitRender(form); - - // Create and add a new checked radio to the group - const newRadio = document.createElement(selector) as - | SbbRadioButtonElement - | SbbRadioButtonPanelElement; - newRadio.setAttribute('name', 'sbb-group-1'); - newRadio.setAttribute('value', '4'); - newRadio.toggleAttribute('checked', true); - fieldset.appendChild(newRadio); - - await waitForLitRender(form); - - expect(elements[0].checked).to.be.false; - expect(newRadio.checked).to.be.true; - expect(elements[0].tabIndex).to.be.equal(-1); - expect(newRadio.tabIndex).to.be.equal(0); - }); + await waitForLitRender(form); + }); - it('should handle moving a radio between the groups', async () => { - elements[0].checked = true; - nativeElements[0].checked = true; - elements[3].checked = true; - nativeElements[3].checked = true; + it('should find connected form', () => { + expect(elements[0].form).to.be.equal(form); + }); - await waitForLitRender(form); + it('first elements of groups should be focusable', async () => { + expect(elements[0].tabIndex).to.be.equal(0); + expect(elements[1].tabIndex).to.be.equal(-1); + expect(elements[2].tabIndex).to.be.equal(-1); + expect(elements[3].tabIndex).to.be.equal(0); + expect(elements[4].tabIndex).to.be.equal(-1); + await compareToNative(); + }); - elements[3].name = elements[0].name; - nativeElements[3].name = nativeElements[0].name; + it('should select on click', async () => { + elements[1].click(); + await waitForLitRender(form); + expect(document.activeElement === elements[1]).to.be.true; - await waitForLitRender(form); + nativeElements[1].click(); + await waitForLitRender(form); - // When moving a checked radio to a group, it has priority and becomes the new checked - expect(elements[0].checked).to.be.false; - expect(elements[3].checked).to.be.true; - expect(elements[0].tabIndex).to.be.equal(-1); - expect(elements[3].tabIndex).to.be.equal(0); - await compareToNative(); - }); + expect(elements[0].tabIndex).to.be.equal(-1); + expect(elements[1].tabIndex).to.be.equal(0); + expect(elements[1].checked).to.be.true; + await compareToNative(); + }); - describe('keyboard interaction', () => { - it('should select on space key', async () => { - elements[0].focus(); - await sendKeys({ press: 'Space' }); + it('should reflect state after programmatic change', async () => { + elements[1].checked = true; + nativeElements[1].checked = true; await waitForLitRender(form); - expect(elements[0].checked).to.be.true; + expect(elements[0].tabIndex).to.be.equal(-1); + expect(elements[1].tabIndex).to.be.equal(0); + await compareToNative(); }); - it('should select and wrap on arrow keys', async () => { + it('should reset on form reset', async () => { elements[1].checked = true; + nativeElements[1].checked = true; await waitForLitRender(form); - elements[1].focus(); - await sendKeys({ press: 'ArrowRight' }); + form.reset(); await waitForLitRender(form); + expect(elements[0].tabIndex).to.be.equal(0); + expect(elements[1].tabIndex).to.be.equal(-1); expect(elements[1].checked).to.be.false; - expect(elements[2].checked).to.be.true; - expect(document.activeElement === elements[2]).to.be.true; + await compareToNative(); + }); - await sendKeys({ press: 'ArrowDown' }); + it('should restore default on form reset', async () => { + elements[1].toggleAttribute('checked', true); + nativeElements[1].toggleAttribute('checked', true); await waitForLitRender(form); - expect(elements[2].checked).to.be.false; - expect(elements[0].checked).to.be.true; - expect(document.activeElement === elements[0]).to.be.true; + elements[0].click(); + nativeElements[0].click(); + await waitForLitRender(form); - await sendKeys({ press: 'ArrowLeft' }); + form.reset(); await waitForLitRender(form); + expect(elements[0].tabIndex).to.be.equal(-1); + expect(elements[1].tabIndex).to.be.equal(0); expect(elements[0].checked).to.be.false; - expect(elements[2].checked).to.be.true; - expect(document.activeElement === elements[2]).to.be.true; + expect(elements[1].checked).to.be.true; + await compareToNative(); + }); - await sendKeys({ press: 'ArrowUp' }); + it('should restore on form restore', async () => { + // Mimic tab restoration. Does not test the full cycle as we can not set the browser in the required state. + elements[0].formStateRestoreCallback('2', 'restore'); + elements[1].formStateRestoreCallback('2', 'restore'); await waitForLitRender(form); - expect(elements[2].checked).to.be.false; + expect(elements[0].checked).to.be.false; expect(elements[1].checked).to.be.true; - expect(document.activeElement === elements[1]).to.be.true; + expect(elements[0].tabIndex).to.be.equal(-1); + expect(elements[1].tabIndex).to.be.equal(0); + }); - // Execute same steps on native and compare the outcome - nativeElements[1].focus(); - await sendKeys({ press: 'ArrowRight' }); - await sendKeys({ press: 'ArrowDown' }); - await sendKeys({ press: 'ArrowLeft' }); - await sendKeys({ press: 'ArrowUp' }); + it('should handle adding a new radio to the group', async () => { + elements[0].checked = true; + await waitForLitRender(form); - // On webkit, native radios do not wrap - if (!isWebkit) { - await compareToNative(); - } - }); + // Create and add a new checked radio to the group + const newRadio = document.createElement(selector) as + | SbbRadioButtonElement + | SbbRadioButtonPanelElement; + newRadio.setAttribute('name', 'sbb-group-1'); + newRadio.setAttribute('value', '4'); + newRadio.toggleAttribute('checked', true); + fieldset.appendChild(newRadio); - it('should handle keyboard interaction outside of a form', async () => { - // Move the radios outside the form - form.parentElement!.append(fieldset); - await waitForLitRender(fieldset); + await waitForLitRender(form); - elements[0].focus(); - await sendKeys({ press: 'ArrowDown' }); - await waitForLitRender(fieldset); expect(elements[0].checked).to.be.false; - expect(elements[1].checked).to.be.true; - expect(document.activeElement === elements[1]).to.be.true; - - await sendKeys({ press: 'ArrowDown' }); - await sendKeys({ press: 'ArrowDown' }); - await waitForLitRender(fieldset); - expect(elements[0].checked).to.be.true; - expect(document.activeElement === elements[0]).to.be.true; + expect(newRadio.checked).to.be.true; + expect(elements[0].tabIndex).to.be.equal(-1); + expect(newRadio.tabIndex).to.be.equal(0); }); - it('should skip disabled elements on arrow keys', async () => { - elements[1].disabled = true; - await waitForLitRender(form); + it('should handle moving a radio between the groups', async () => { + elements[0].checked = true; + nativeElements[0].checked = true; + elements[3].checked = true; + nativeElements[3].checked = true; - elements[0].focus(); - await sendKeys({ press: 'ArrowRight' }); await waitForLitRender(form); - expect(elements[0].checked).to.be.false; - expect(elements[2].checked).to.be.true; - expect(document.activeElement === elements[2]).to.be.true; + elements[3].name = elements[0].name; + nativeElements[3].name = nativeElements[0].name; - await sendKeys({ press: 'ArrowLeft' }); await waitForLitRender(form); - expect(elements[2].checked).to.be.false; - expect(elements[0].checked).to.be.true; - expect(document.activeElement === elements[0]).to.be.true; + // When moving a checked radio to a group, it has priority and becomes the new checked + expect(elements[0].checked).to.be.false; + expect(elements[3].checked).to.be.true; + expect(elements[0].tabIndex).to.be.equal(-1); + expect(elements[3].tabIndex).to.be.equal(0); + await compareToNative(); }); - it('should skip non-visible elements on arrow keys', async () => { - elements[1].style.display = 'none'; - await waitForLitRender(form); + describe('keyboard interaction', () => { + it('should select on space key', async () => { + elements[0].focus(); + await sendKeys({ press: 'Space' }); + await waitForLitRender(form); + + expect(elements[0].checked).to.be.true; + }); + + it('should select and wrap on arrow keys', async () => { + elements[1].checked = true; + await waitForLitRender(form); + elements[1].focus(); + + await sendKeys({ press: 'ArrowRight' }); + await waitForLitRender(form); + + expect(elements[1].checked).to.be.false; + expect(elements[2].checked).to.be.true; + expect(document.activeElement === elements[2]).to.be.true; + + await sendKeys({ press: 'ArrowDown' }); + await waitForLitRender(form); + + expect(elements[2].checked).to.be.false; + expect(elements[0].checked).to.be.true; + expect(document.activeElement === elements[0]).to.be.true; + + await sendKeys({ press: 'ArrowLeft' }); + await waitForLitRender(form); + + expect(elements[0].checked).to.be.false; + expect(elements[2].checked).to.be.true; + expect(document.activeElement === elements[2]).to.be.true; + + await sendKeys({ press: 'ArrowUp' }); + await waitForLitRender(form); + + expect(elements[2].checked).to.be.false; + expect(elements[1].checked).to.be.true; + expect(document.activeElement === elements[1]).to.be.true; + + // Execute same steps on native and compare the outcome + nativeElements[1].focus(); + await sendKeys({ press: 'ArrowRight' }); + await sendKeys({ press: 'ArrowDown' }); + await sendKeys({ press: 'ArrowLeft' }); + await sendKeys({ press: 'ArrowUp' }); + + // On webkit, native radios do not wrap + if (!isWebkit) { + await compareToNative(); + } + }); + + it('should handle keyboard interaction outside of a form', async () => { + // Move the radios outside the form + form.parentElement!.append(fieldset); + await waitForLitRender(fieldset); + + elements[0].focus(); + await sendKeys({ press: 'ArrowDown' }); + await waitForLitRender(fieldset); + expect(elements[0].checked).to.be.false; + expect(elements[1].checked).to.be.true; + expect(document.activeElement === elements[1]).to.be.true; + + await sendKeys({ press: 'ArrowDown' }); + await sendKeys({ press: 'ArrowDown' }); + await waitForLitRender(fieldset); + expect(elements[0].checked).to.be.true; + expect(document.activeElement === elements[0]).to.be.true; + }); + + it('should skip disabled elements on arrow keys', async () => { + elements[1].disabled = true; + await waitForLitRender(form); + + elements[0].focus(); + await sendKeys({ press: 'ArrowRight' }); + await waitForLitRender(form); + + expect(elements[0].checked).to.be.false; + expect(elements[2].checked).to.be.true; + expect(document.activeElement === elements[2]).to.be.true; + + await sendKeys({ press: 'ArrowLeft' }); + await waitForLitRender(form); + + expect(elements[2].checked).to.be.false; + expect(elements[0].checked).to.be.true; + expect(document.activeElement === elements[0]).to.be.true; + }); + + it('should skip non-visible elements on arrow keys', async () => { + elements[1].style.display = 'none'; + await waitForLitRender(form); + + elements[0].focus(); + await sendKeys({ press: 'ArrowRight' }); + await waitForLitRender(form); + + expect(elements[0].checked).to.be.false; + expect(elements[2].checked).to.be.true; + expect(document.activeElement === elements[2]).to.be.true; + + await sendKeys({ press: 'ArrowLeft' }); + await waitForLitRender(form); + + expect(elements[2].checked).to.be.false; + expect(elements[0].checked).to.be.true; + expect(document.activeElement === elements[0]).to.be.true; + }); + }); - elements[0].focus(); - await sendKeys({ press: 'ArrowRight' }); - await waitForLitRender(form); + describe('disabled state', () => { + it('should result :disabled', async () => { + elements[0].disabled = true; + await waitForLitRender(form); - expect(elements[0].checked).to.be.false; - expect(elements[2].checked).to.be.true; - expect(document.activeElement === elements[2]).to.be.true; + expect(elements[0]).to.match(':disabled'); + expect(elements[1].tabIndex).to.be.equal(0); + }); - await sendKeys({ press: 'ArrowLeft' }); - await waitForLitRender(form); + it('should result :disabled if a fieldSet is', async () => { + fieldset.toggleAttribute('disabled', true); + await waitForLitRender(form); - expect(elements[2].checked).to.be.false; - expect(elements[0].checked).to.be.true; - expect(document.activeElement === elements[0]).to.be.true; - }); - }); + expect(elements[0]).to.match(':disabled'); + }); - describe('disabled state', () => { - it('should result :disabled', async () => { - elements[0].disabled = true; - await waitForLitRender(form); + it('should do nothing when clicked', async () => { + elements[0].disabled = true; + await waitForLitRender(form); - expect(elements[0]).to.match(':disabled'); - expect(elements[1].tabIndex).to.be.equal(0); - }); + elements[0].click(); + await waitForLitRender(form); - it('should result :disabled if a fieldSet is', async () => { - fieldset.toggleAttribute('disabled', true); - await waitForLitRender(form); + expect(elements[0].checked).to.be.false; + }); - expect(elements[0]).to.match(':disabled'); + it('should update tabindex when the first element is disabled', async () => { + expect(elements[0].tabIndex).to.be.equal(0); + elements[0].disabled = true; + await waitForLitRender(form); + + expect(elements[0].tabIndex).to.be.equal(-1); + expect(elements[1].tabIndex).to.be.equal(0); + }); }); + }); - it('should do nothing when clicked', async () => { - elements[0].disabled = true; - await waitForLitRender(form); + describe('multiple groups with the same name', () => { + let root: HTMLElement; + + beforeEach(async () => { + root = await fixture(html` +
+
+ <${tagSingle} value="1" name="sbb-group-1">1 + <${tagSingle} value="2" name="sbb-group-1">2 + <${tagSingle} value="3" name="sbb-group-1">3 +
+
+ <${tagSingle} value="1" name="sbb-group-1">1 + <${tagSingle} value="2" name="sbb-group-1">2 + <${tagSingle} value="3" name="sbb-group-1">3 +
+
+ `); + + form = root.querySelector('form#main')!; + elements = Array.from(root.querySelectorAll(selector)); + await waitForLitRender(root); + }); + it('groups should be independent', async () => { + expect(elements[0].tabIndex).to.be.equal(0); + expect(elements[3].tabIndex).to.be.equal(0); + + // Check the first element of each group elements[0].click(); - await waitForLitRender(form); + elements[3].click(); + await waitForLitRender(root); - expect(elements[0].checked).to.be.false; + expect(elements[0].tabIndex).to.be.equal(0); + expect(elements[0].checked).to.be.true; + expect(elements[3].tabIndex).to.be.equal(0); + expect(elements[3].checked).to.be.true; }); - it('should update tabindex when the first element is disabled', async () => { - expect(elements[0].tabIndex).to.be.equal(0); - elements[0].disabled = true; - await waitForLitRender(form); + it('groups should be independent when keyboard navigated', async () => { + elements[0].focus(); - expect(elements[0].tabIndex).to.be.equal(-1); - expect(elements[1].tabIndex).to.be.equal(0); + await sendKeys({ press: 'ArrowUp' }); + await waitForLitRender(root); + + expect(elements[2].tabIndex).to.be.equal(0); + expect(elements[2].checked).to.be.true; + expect(elements[5].tabIndex).to.be.equal(-1); + expect(elements[5].checked).to.be.false; }); }); }); diff --git a/src/elements/radio-button/radio-button-panel/radio-button-panel.ts b/src/elements/radio-button/radio-button-panel/radio-button-panel.ts index 224947ccb5..f5daca38fd 100644 --- a/src/elements/radio-button/radio-button-panel/radio-button-panel.ts +++ b/src/elements/radio-button/radio-button-panel/radio-button-panel.ts @@ -74,7 +74,8 @@ class SbbRadioButtonPanelElement extends SbbPanelMixin( */ protected override updateFocusableRadios(): void { super.updateFocusableRadios(); - const radios = (RadioButtonRegistry.getRadios(this.name) || []) as SbbRadioButtonPanelElement[]; + const radios = (RadioButtonRegistry.getRadios(this.name, this.form) || + []) as SbbRadioButtonPanelElement[]; radios .filter((r) => !r.disabled && r._hasSelectionExpansionPanelElement)