diff --git a/src/components/validation-container/validation-container.spec.ts b/src/components/validation-container/validation-container.spec.ts
index 38fa3901f..6dc43a657 100644
--- a/src/components/validation-container/validation-container.spec.ts
+++ b/src/components/validation-container/validation-container.spec.ts
@@ -1,8 +1,11 @@
-import { elementUpdated, fixture, html } from '@open-wc/testing';
+import { elementUpdated, expect, fixture, html } from '@open-wc/testing';
import type { TemplateResult } from 'lit';
import { defineComponents } from '#internals/definitions/defineComponents.js';
import { ValidityHelpers } from '#internals/testing/validity-helpers.spec.js';
import IgcInputComponent from '../input/input.js';
+import IgcValidationContainerComponent, {
+ type ValidationContainerConfig,
+} from './validation-container.js';
describe('Validation container', () => {
let input: IgcInputComponent;
@@ -103,4 +106,59 @@ describe('Validation container', () => {
ValidityHelpers.hasInvalidStyles(input).to.be.true;
ValidityHelpers.hasSlottedContent(input, valueMissingSlot).to.be.true;
});
+
+ it('projects validation messages for a host that starts out invalid', async () => {
+ await createFixture(html`
+
+ Value missing
+
+ `);
+
+ // Wait for the second host render requested by the container.
+ await elementUpdated(input);
+
+ ValidityHelpers.hasInvalidStyles(input).to.be.true;
+ ValidityHelpers.hasSlots(input, valueMissingSlot).to.be.true;
+ ValidityHelpers.hasSlottedContent(input, valueMissingSlot).to.be.true;
+ });
+
+ describe('create()', () => {
+ const projectedHelperSlot = `slot[name='${helperSlot}']`;
+
+ async function createContainer(config?: ValidationContainerConfig) {
+ return fixture(
+ IgcValidationContainerComponent.create(input, config)
+ );
+ }
+
+ beforeEach(async () => {
+ await createFixture(html``);
+ });
+
+ it('projects a helper-text slot by default', async () => {
+ const container = await createContainer();
+
+ expect(container.id).to.equal(helperSlot);
+ expect(container.querySelector(projectedHelperSlot)).not.to.be.null;
+ });
+
+ it('does not project a helper-text slot when `hasHelperText` is false', async () => {
+ const container = await createContainer({ hasHelperText: false });
+
+ expect(container.hasAttribute('id')).to.be.false;
+ expect(container.querySelector(projectedHelperSlot)).to.be.null;
+ });
+
+ it('applies the id, slot and part from the configuration', async () => {
+ const container = await createContainer({
+ id: 'custom-id',
+ slot: 'anchor',
+ part: 'custom-part',
+ });
+
+ expect(container.id).to.equal('custom-id');
+ expect(container.slot).to.equal('anchor');
+ expect(container.part.contains('custom-part')).to.be.true;
+ });
+ });
});
diff --git a/src/components/validation-container/validation-container.ts b/src/components/validation-container/validation-container.ts
index ba4fcabb5..6df64e202 100644
--- a/src/components/validation-container/validation-container.ts
+++ b/src/components/validation-container/validation-container.ts
@@ -29,38 +29,39 @@ export interface ValidationContainerConfig {
hasHelperText?: boolean;
}
-const ALL_SLOTS_SELECTOR = 'slot';
-const QUERY_CONFIG: AssignedNodesOptions = { flatten: true };
-
/**
- * Validity flags rendered as validation message slots, in a stable order so the
- * generated slots are deterministic across browsers.
+ * Validity flags and their slot names, in a stable order so the generated slots
+ * are deterministic across browsers.
*/
-const VALIDITY_KEYS: ReadonlyArray = [
- 'badInput',
- 'customError',
- 'patternMismatch',
- 'rangeOverflow',
- 'rangeUnderflow',
- 'stepMismatch',
- 'tooLong',
- 'tooShort',
- 'typeMismatch',
- 'valueMissing',
-];
+const VALIDITY_SLOTS: ReadonlyArray<
+ readonly [keyof ValidityStateFlags, string]
+> = (
+ [
+ 'badInput',
+ 'customError',
+ 'patternMismatch',
+ 'rangeOverflow',
+ 'rangeUnderflow',
+ 'stepMismatch',
+ 'tooLong',
+ 'tooShort',
+ 'typeMismatch',
+ 'valueMissing',
+ ] as const
+).map((key) => [key, toKebabCase(key)] as const);
/**
- * Yields the active validation slot names for the given validity state, in a
- * stable order (`invalid` first, then each failing constraint).
+ * Yields the active validation slot names for the given validity state:
+ * `invalid` first, then each failing constraint.
*/
function* activeValidationSlots(validity: ValidityState): Generator {
if (!validity.valid) {
yield 'invalid';
}
- for (const key of VALIDITY_KEYS) {
+ for (const [key, slot] of VALIDITY_SLOTS) {
if (validity[key]) {
- yield toKebabCase(key);
+ yield slot;
}
}
}
@@ -99,14 +100,17 @@ export default class IgcValidationContainerComponent extends LitElement {
? html``
: nothing;
- // `hasUpdated` is false during SSR and the host's hydrating render, so both
- // emit `nothing`. The real validation slots are projected post-hydration.
+ // `hasUpdated` is false during SSR and the hydrating render, so both emit
+ // `nothing` and the slots are projected on the next host render (see
+ // `firstUpdated` in the container).
const validationSlots = host.hasUpdated
? Iterator.from(activeValidationSlots(host.validity))
.map((name) => html``)
.toArray()
: nothing;
+ // `?invalid` tracks host re-renders; the internal invalid/reset events cover
+ // a form reset, which restores the value without re-rendering the host.
return html`
(ALL_SLOTS_SELECTOR)
- );
let isProjectionEmpty = true;
- for (const slot of slots) {
- if (isEmpty(slot.assignedElements(QUERY_CONFIG))) {
+ for (const slot of this.renderRoot.querySelectorAll('slot')) {
+ if (isEmpty(slot.assignedElements({ flatten: true }))) {
continue;
}
isProjectionEmpty = false;
- if (slot.name && slot.name !== 'helper-text') {
+ if (slot.name !== 'helper-text') {
validation.add(slot.name);
}
}
@@ -220,13 +221,13 @@ export default class IgcValidationContainerComponent extends LitElement {
return { isProjectionEmpty, validation };
}
- protected _renderValidationMessage(
+ private _renderValidationMessage(
slotName: string,
projectedSlots: ReadonlySet
): TemplateResult {
- const hasProjectedIcon = projectedSlots.has(slotName);
- const parts = { 'validation-message': true, empty: !hasProjectedIcon };
- const icon = hasProjectedIcon
+ const hasProjectedContent = projectedSlots.has(slotName);
+ const parts = { 'validation-message': true, empty: !hasProjectedContent };
+ const icon = hasProjectedContent
? html`
`;
}
- protected _renderHelper(
- hasValidationProjection: boolean
+ private _renderHelper(
+ projectedSlots: ReadonlySet
): TemplateResult | typeof nothing {
- return this.invalid && hasValidationProjection
+ return this.invalid && projectedSlots.size > 0
? nothing
: html``;
}
protected override firstUpdated(): void {
- // The first render is intentionally neutral to match the SSR output during
- // hydration. Only reconcile when the field hydrated in an invalid state, and
- // do it off the current update cycle so we don't schedule an update as a side
- // effect of the one in progress, projecting the real validation messages now
- // that `hasUpdated` is true.
+ // `create` omits the validation slots until the host has updated. If the
+ // host hydrated invalid, ask it to re-render so the slots are projected;
+ // their `slotchange` then updates this container.
if (this.invalid) {
- queueMicrotask(() => this.requestUpdate());
+ this.target.requestUpdate();
}
}
@@ -274,7 +273,7 @@ export default class IgcValidationContainerComponent extends LitElement {
part=${partMap({ 'helper-text': true, empty: isProjectionEmpty })}
aria-live="polite"
>
- ${messages}${this._renderHelper(!isEmpty(validation))}
+ ${messages}${this._renderHelper(validation)}
`;
}