From 688d33c46d988409c1ec5cbaa74ba98ad018d0a8 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Thu, 3 Sep 2026 17:10:53 +0300 Subject: [PATCH] fix(validator): project validation messages for a host that starts invalid The container's first render is neutral for SSR parity and `create` skips the light DOM projection slots until the host has updated. A host that hydrated invalid (``) never re-rendered, so its validation messages stayed hidden. The container now asks the host for a follow-up render from `firstUpdated` when it is invalid. Also: - cache kebab-cased slot names at module load instead of per render - take the projected-slot set in `_renderHelper` and make the render helpers private - tighten comments and the `target` docs; the `?invalid` note wrongly claimed the internal events cover failed submissions - add specs for the initially invalid host and the `create()` config --- .../validation-container.spec.ts | 60 +++++++++- .../validation-container.ts | 103 +++++++++--------- 2 files changed, 110 insertions(+), 53 deletions(-) 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`