Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions components/form/demo/custom-form-element-container.js
Comment thread
EdwinACL831 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import '../../inputs/input-group.js';
import '../../inputs/input-number.js';
import '../../inputs/input-text.js';
import { html, LitElement } from 'lit';
import { FormElementContainerMixin } from '../form-element-container-mixin.js';
import { inputLabelStyles } from '../../inputs/input-label-styles.js';
import { inputStyles } from '../../inputs/input-styles.js';

class CustomFormElementContainer extends FormElementContainerMixin(LitElement) {

static styles = [inputStyles, inputLabelStyles];

render() {
return html`
<d2l-input-group>
Comment thread
EdwinACL831 marked this conversation as resolved.
<div>
<label for="native-input" class="d2l-input-label d2l-input-label-required">First Name</label>
<input
id="native-input"
type="text"
name="first-name"
minlength="4"
maxlength="15"
required
class="d2l-input">
</div>
<d2l-input-text label="Middle Name" name="middle-name" minlength="4" maxlength="8"></d2l-input-text>
Comment thread
EdwinACL831 marked this conversation as resolved.
<d2l-input-text label="Last Name" name="last-name" required minlength="4" maxlength="15"></d2l-input-text>
<d2l-input-text
label="Telephone Number"
type="tel"
name="phone-number"
pattern="[0-9]{3}-[0-9]{3}-[0-9]{4}"
required
></d2l-input-text>
<d2l-input-number label="Age" name="age" required min="18" max="23"></d2l-input-number>
</d2l-input-group>
`;
}
}

customElements.define('d2l-custom-form-element-container', CustomFormElementContainer);
16 changes: 2 additions & 14 deletions components/form/demo/form-panel-demo.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@

import './custom-form-element-container.js';
import '../../button/button-icon.js';
import '../../colors/colors.js';
import '../../expand-collapse/expand-collapse-content.js';
import '../../inputs/input-number.js';
import '../../inputs/input-text.js';
import '../form.js';
import '../../collapsible-panel/collapsible-panel.js';
import { css, html, LitElement } from 'lit';
Expand Down Expand Up @@ -52,18 +51,7 @@ class FormPanelDemo extends LitElement {
@d2l-collapsible-panel-expand=${this._onExpand}
@d2l-collapsible-panel-collapse=${this._onCollapse}>
<d2l-form @d2l-form-invalid=${this._onInvalid} @d2l-form-submit=${this._onSubmit}>
<div class="d2l-form-panel-demo-container">
<d2l-input-text label="First Name" name="first-name" required minlength="4" maxlength="15"></d2l-input-text>
</div>
<div class="d2l-form-panel-demo-container">
<d2l-input-text label="Middle Name" name="middle-name" minlength="4" maxlength="8"></d2l-input-text>
</div>
<div class="d2l-form-panel-demo-container">
<d2l-input-text label="Last Name" name="last-name" required minlength="4" maxlength="15"></d2l-input-text>
</div>
<div class="d2l-form-panel-demo-container">
<d2l-input-number label="Age" name="age" required min="18" max="23"></d2l-input-number>
</div>
<d2l-custom-form-element-container></d2l-custom-form-element-container>
Comment thread
EdwinACL831 marked this conversation as resolved.
</d2l-form>
</d2l-collapsible-panel>
`;
Expand Down
28 changes: 28 additions & 0 deletions components/form/docs/form-element-container-mixin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# FormElementContainerMixin

Extending `FormElementContainerMixin` will allow a custom element's children (native or [custom](./form-element-mixin.md)) to participate in the parent `<d2l-form>`.

## Usage

Simply extend the `FormElementContainerMixin`:

```javascript
import { FormElementContainerMixin } from '@brightspace-ui/core/form/form-element-container-mixin.js';

class MyCustomFormElementContainer extends FormElementContainerMixin(LitElement) {
render() {
html`
<d2l-input-text
label="First Name"
name="first-name"
required></d2l-input-text>
<d2l-input-text
label="Last Name"
name="last-name"
required></d2l-input-text>
`;
}
}

customElements.define('my-custom-form-element-container', MyCustomFormElementContainer);
```
8 changes: 8 additions & 0 deletions components/form/form-element-container-mixin.js
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change in combination with the changes at components/form/form-helper.js is how now the form is able to "find" these custom form element containers

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* When applied to a custom element, form elements within will participate in the form.
*/
export const FormElementContainerMixin = superClass => class extends superClass {
get isCustomFormElementContainer() {
return true;
}
Comment thread
EdwinACL831 marked this conversation as resolved.
};
26 changes: 18 additions & 8 deletions components/form/form-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,34 @@ export const isElement = (node) => node && node.nodeType === Node.ELEMENT_NODE;

export const isCustomElement = (node) => isElement(node) && node.nodeName.indexOf('-') !== -1;

export const isCustomFormElement = (node) => isCustomElement(node) && !!node.formAssociated;
export const isCustomFormElement = (node) => isCustomElement(node) && Boolean(node.formAssociated);

export const isCustomFormElementContainer = (node) => isCustomElement(node) && Boolean(node.isCustomFormElementContainer);

export const isNativeFormElement = (node) => {
if (!isElement(node)) {
return false;
}
const nodeName = node.nodeName.toLowerCase();
return !!formElements[nodeName];
return Boolean(formElements[nodeName]);
};

const getElementChildren = (elem) => {
if (isCustomFormElementContainer(elem)) {
return elem.shadowRoot.children;
}

return elem.tagName === 'SLOT' && ['primary', 'secondary'].includes(elem.name) ? elem.assignedNodes() : elem.children;
};

const _findFormElementsHelper = (ele, eles, isFormElementPredicate, visitChildrenPredicate) => {
if (isNativeFormElement(ele) || isCustomFormElement(ele) || isFormElementPredicate(ele)) {
eles.push(ele);
const _findFormElementsHelper = (elem, elems, isFormElementPredicate, visitChildrenPredicate) => {
if (isNativeFormElement(elem) || isCustomFormElement(elem) || isFormElementPredicate(elem)) {
elems.push(elem);
}
if (visitChildrenPredicate(ele)) {
const children = ele.tagName === 'SLOT' && ['primary', 'secondary'].includes(ele.name) ? ele.assignedNodes() : ele.children;
if (visitChildrenPredicate(elem)) {
const children = getElementChildren(elem);
for (const child of children) {
_findFormElementsHelper(child, eles, isFormElementPredicate, visitChildrenPredicate);
_findFormElementsHelper(child, elems, isFormElementPredicate, visitChildrenPredicate);
}
}
};
Expand Down
69 changes: 55 additions & 14 deletions components/form/test/form-helper.test.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import './form-element.js';
import '../../inputs/input-text.js';
Copy link
Copy Markdown
Contributor Author

@EdwinACL831 EdwinACL831 May 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is needed because the class defined using defineCE uses it within its render method.

import '../../status-indicator/status-indicator.js';
import '../../tooltip/tooltip.js';
import { defineCE, expect, fixture } from '@brightspace-ui/testing';
import { findFormElements, flattenMap, getFormElementData, isCustomElement, isCustomFormElement, isElement, isNativeFormElement, tryGetLabelText } from '../form-helper.js';
import { findFormElements, flattenMap, getFormElementData, isCustomElement, isCustomFormElement, isCustomFormElementContainer, isElement, isNativeFormElement, tryGetLabelText } from '../form-helper.js';
import { html, LitElement } from 'lit';
import { FormElementContainerMixin } from '../form-element-container-mixin.js';

const buttonFixture = html`<button type="button">Add to favorites</button>`;

Expand Down Expand Up @@ -69,24 +71,50 @@ const h1Fixture = html`<h1>Beetles</h1>`;

const formElementFixture = html`<d2l-test-form-element></d2l-test-form-element>`;

const nestedCustomFormElementContainerTag = defineCE(class extends FormElementContainerMixin(LitElement) {
render() {
return html`
<label for="nested-native-input">Name</label>
<input
id="nested-native-input"
type="text"
name="name"
minlength="4"
maxlength="15"
required
>
<d2l-input-text
label="Telephone Number"
type="tel"
name="phone"
pattern="[0-9]{3}-[0-9]{3}-[0-9]{4}"
required
></d2l-input-text>
`;
}
});

const nestedCustomFormElementContainerFixture = `<${nestedCustomFormElementContainerTag}></${nestedCustomFormElementContainerTag}>`;

describe('form-helper', () => {

describe('elements', () => {

[
{ tag: 'button', fixture: buttonFixture, expected: { isElement: true, isCustomElement: false, isNativeFormElement: true, isCustomFormElement: false } },
{ tag: 'fieldset', fixture: fieldsetFixture, expected: { isElement: true, isCustomElement: false, isNativeFormElement: true, isCustomFormElement: false } },
{ tag: 'input', fixture: inputFixture, expected: { isElement: true, isCustomElement: false, isNativeFormElement: true, isCustomFormElement: false } },
{ tag: 'object', fixture: objectFixture, expected: { isElement: true, isCustomElement: false, isNativeFormElement: true, isCustomFormElement: false } },
{ tag: 'output', fixture: outputFixture, expected: { isElement: true, isCustomElement: false, isNativeFormElement: true, isCustomFormElement: false } },
{ tag: 'select', fixture: selectFixture, expected: { isElement: true, isCustomElement: false, isNativeFormElement: true, isCustomFormElement: false } },
{ tag: 'textarea', fixture: textareaFixture, expected: { isElement: true, isCustomElement: false, isNativeFormElement: true, isCustomFormElement: false } },
{ tag: 'div', fixture: divFixture, expected: { isElement: true, isCustomElement: false, isNativeFormElement: false, isCustomFormElement: false } },
{ tag: 'label', fixture: labelFixture, expected: { isElement: true, isCustomElement: false, isNativeFormElement: false, isCustomFormElement: false } },
{ tag: 'form', fixture: formFixture, expected: { isElement: true, isCustomElement: false, isNativeFormElement: false, isCustomFormElement: false } },
{ tag: 'h1', fixture: h1Fixture, expected: { isElement: true, isCustomElement: false, isNativeFormElement: false, isCustomFormElement: false } },
{ tag: 'd2l-status-indicator', fixture: d2lStatusIndicatorFixture, expected: { isElement: true, isCustomElement: true, isNativeFormElement: false, isCustomFormElement: false } },
{ tag: 'd2l-test-form-element', fixture: formElementFixture, expected: { isElement: true, isCustomElement: true, isNativeFormElement: false, isCustomFormElement: true } }
{ tag: 'button', fixture: buttonFixture, expected: { isElement: true, isCustomElement: false, isNativeFormElement: true, isCustomFormElement: false, isCustomFormElementContainer: false } },
{ tag: 'fieldset', fixture: fieldsetFixture, expected: { isElement: true, isCustomElement: false, isNativeFormElement: true, isCustomFormElement: false, isCustomFormElementContainer: false } },
{ tag: 'input', fixture: inputFixture, expected: { isElement: true, isCustomElement: false, isNativeFormElement: true, isCustomFormElement: false, isCustomFormElementContainer: false } },
{ tag: 'object', fixture: objectFixture, expected: { isElement: true, isCustomElement: false, isNativeFormElement: true, isCustomFormElement: false, isCustomFormElementContainer: false } },
{ tag: 'output', fixture: outputFixture, expected: { isElement: true, isCustomElement: false, isNativeFormElement: true, isCustomFormElement: false, isCustomFormElementContainer: false } },
{ tag: 'select', fixture: selectFixture, expected: { isElement: true, isCustomElement: false, isNativeFormElement: true, isCustomFormElement: false, isCustomFormElementContainer: false } },
{ tag: 'textarea', fixture: textareaFixture, expected: { isElement: true, isCustomElement: false, isNativeFormElement: true, isCustomFormElement: false, isCustomFormElementContainer: false } },
{ tag: 'div', fixture: divFixture, expected: { isElement: true, isCustomElement: false, isNativeFormElement: false, isCustomFormElement: false, isCustomFormElementContainer: false } },
{ tag: 'label', fixture: labelFixture, expected: { isElement: true, isCustomElement: false, isNativeFormElement: false, isCustomFormElement: false, isCustomFormElementContainer: false } },
{ tag: 'form', fixture: formFixture, expected: { isElement: true, isCustomElement: false, isNativeFormElement: false, isCustomFormElement: false, isCustomFormElementContainer: false } },
{ tag: 'h1', fixture: h1Fixture, expected: { isElement: true, isCustomElement: false, isNativeFormElement: false, isCustomFormElement: false, isCustomFormElementContainer: false } },
{ tag: 'd2l-status-indicator', fixture: d2lStatusIndicatorFixture, expected: { isElement: true, isCustomElement: true, isNativeFormElement: false, isCustomFormElement: false, isCustomFormElementContainer: false } },
{ tag: 'd2l-test-form-element', fixture: formElementFixture, expected: { isElement: true, isCustomElement: true, isNativeFormElement: false, isCustomFormElement: true, isCustomFormElementContainer: false } },
{ tag: nestedCustomFormElementContainerTag, fixture: nestedCustomFormElementContainerFixture, expected: { isElement: true, isCustomElement: true, isNativeFormElement: false, isCustomFormElement: false, isCustomFormElementContainer: true } }
].forEach(({ tag, fixture: eleFixture, expected }) => {

describe(tag, () => {
Expand All @@ -113,6 +141,10 @@ describe('form-helper', () => {
expect(isCustomFormElement(ele)).to.equal(expected.isCustomFormElement);
});

it(`${tag} should ${expected.isCustomFormElementContainer ? '' : 'not '}be a custom form element container`, () => {
expect(isCustomFormElementContainer(ele)).to.equal(expected.isCustomFormElementContainer);
});

});

});
Expand Down Expand Up @@ -336,6 +368,15 @@ describe('form-helper', () => {
expect(formElements).to.include.members([fakeFormElement]);
});

it('should find elements nested in the custom form element container\'s shadow DOM', async() => {
root = await fixture(nestedCustomFormElementContainerFixture);
const nestedNativeInput = root.shadowRoot.querySelector('#nested-native-input');
const nestedCustomInput = root.shadowRoot.querySelector('d2l-input-text[name="phone"]');
const formElements = findFormElements(root);

expect(formElements).to.include.members([nestedNativeInput, nestedCustomInput]);
});

});

describe('flattenMap', () => {
Expand Down