diff --git a/bun.lock b/bun.lock index 1c03c238..567db831 100644 --- a/bun.lock +++ b/bun.lock @@ -320,7 +320,7 @@ }, "packages/compiler": { "name": "@wrnexus/compiler", - "version": "0.8.26", + "version": "0.8.27", "dependencies": { "@wrnexus/csr": "workspace:*", "@wrnexus/store": "workspace:*", @@ -688,7 +688,7 @@ }, "packages/ui": { "name": "@wrnexus/ui", - "version": "0.8.57", + "version": "0.8.58", "dependencies": { "@wrnexus/core": "workspace:*", }, diff --git a/packages/compiler/package.json b/packages/compiler/package.json index e80eefda..7852f950 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/compiler", - "version": "0.8.26", + "version": "0.8.27", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/compiler/src/codegen.ts b/packages/compiler/src/codegen.ts index 992f47af..41fce70e 100644 --- a/packages/compiler/src/codegen.ts +++ b/packages/compiler/src/codegen.ts @@ -912,7 +912,7 @@ function renderNestedComponentInvocation( const compiledValue = wholeExpression ? `\${__wrnProp(${ctx.resolveExpr(wholeExpression)})}` - : compileAttrValue(attr.value, ctx); + : compileAttrValue(attr.value, ctx, attr.name === "pattern"); const rendered = ` ${attr.name}="${compiledValue}"`; @@ -2254,7 +2254,7 @@ function compileText(raw: string, ctx: CompCtx): string { } /** Compile an attribute value; `{expr}` is baked server-side (loop vars stay literal). */ -function compileAttrValue(raw: string, ctx: CompCtx): string { +function compileAttrValue(raw: string, ctx: CompCtx, regexPattern = false): string { if (!raw.includes("{")) return escLit(attrEscape(raw)); let out = ""; let last = 0; @@ -2263,7 +2263,9 @@ function compileAttrValue(raw: string, ctx: CompCtx): string { while ((m = INTERP_RE.exec(raw))) { out += escLit(attrEscape(raw.slice(last, m.index))); const expr = m[1]!.trim(); - if (ctx.loopVars && exprRefsState(expr, ctx.loopVars)) { + if (regexPattern && /^\d+(?:,\d*)?$/.test(expr)) { + out += escLit(attrEscape(m[0])); // Regex quantifiers are not template expressions. + } else if (ctx.loopVars && exprRefsState(expr, ctx.loopVars)) { out += escLit(`{${expr}}`); // hydrated per-item by the list renderer } else { out += `\${__wrnAttr(${ctx.resolveExpr(expr)})}`; @@ -2628,7 +2630,7 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string { const compiledValue = isExplicitComponentMount && wholeExpression ? `\${__wrnProp(${elementContext.resolveExpr(wholeExpression)})}` - : compileAttrValue(a.value, elementContext); + : compileAttrValue(a.value, elementContext, a.name === "pattern"); /* * A constraint attribute whose value is only known at render time (a diff --git a/packages/compiler/test/pattern-quantifiers.test.ts b/packages/compiler/test/pattern-quantifiers.test.ts new file mode 100644 index 00000000..8049ca79 --- /dev/null +++ b/packages/compiler/test/pattern-quantifiers.test.ts @@ -0,0 +1,22 @@ +import { test, expect } from "bun:test"; +import { parse, generate } from "../src/index.ts"; + +test("HTML and component pattern attributes preserve regex quantifiers", () => { + for (const tag of ["input", "Input"]) { + for (const pattern of ["[0-9]{6}", "[a-z]{2,8}", "[A-Z]{1,}"]) { + const code = generate(parse(`page Example { view { <${tag} pattern="${pattern}" /> } }`)); + expect(code).toContain(pattern); + expect(code).not.toContain("__wrnAttr(6)"); + expect(code).not.toContain("__wrnAttr(2,8)"); + } + } +}); + +test("dynamic pattern props still compile as expressions", () => { + const code = generate( + parse( + 'component Example { props { pattern: string = "" } view { } }', + ), + ); + expect(code).toContain("__wrnAttr(pattern)"); +}); diff --git a/packages/ui/COMPONENTS.md b/packages/ui/COMPONENTS.md index 4f65681d..a3606030 100644 --- a/packages/ui/COMPONENTS.md +++ b/packages/ui/COMPONENTS.md @@ -413,7 +413,7 @@ Theme-aware, responsive file input component. Theme-aware, responsive input component. - Mount: `data-component="Input"` -- Props: `size: string = "default"`, `color: string = "primary"`, `id: string = ""`, `name: string = ""`, `label: string = "Input"`, `hiddenLabel: boolean = false`, `placeholder: string = ""`, `value: string = ""`, `type: string = "text"`, `variant: string = "normal"`, `icon: string = ""`, `iconPosition: string = "start"`, `helperText: string = ""`, `cornerHint: string = ""`, `error: string = ""`, `inline: boolean = false`, `readonly: boolean = false`, `disabled: boolean = false`, `required: boolean = false`, `autocomplete: string = ""`, `inputmode: string = ""`, `minlength: string = ""`, `maxlength: string = ""`, `pattern: string = ""`, `min: string = ""`, `max: string = ""`, `step: string = ""`, `class: string = ""` +- Props: `size: string = "default"`, `color: string = "primary"`, `id: string = ""`, `name: string = ""`, `label: string = "Input"`, `hiddenLabel: boolean = false`, `placeholder: string = ""`, `value: string = ""`, `type: string = "text"`, `variant: string = "normal"`, `icon: string = ""`, `iconPosition: string = "start"`, `helperText: string = ""`, `cornerHint: string = ""`, `error: string = ""`, `invalidMessage: string = ""`, `inline: boolean = false`, `readonly: boolean = false`, `disabled: boolean = false`, `required: boolean = false`, `autocomplete: string = ""`, `inputmode: string = ""`, `minlength: string = ""`, `maxlength: string = ""`, `pattern: string = ""`, `min: string = ""`, `max: string = ""`, `step: string = ""`, `class: string = ""` - Slots: None - Outputs: `input({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `change({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `focus({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `blur({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `invalid({ value: string | number | boolean | null | object; name: string; message: string; sourceEvent: Event })`, `keydown({ key: string; value: string | number | boolean | null | object; name: string; sourceEvent: Event })`, `keyup({ key: string; value: string | number | boolean | null | object; name: string; sourceEvent: Event })` diff --git a/packages/ui/component-reference.json b/packages/ui/component-reference.json index bfba62ee..ebd7824a 100644 --- a/packages/ui/component-reference.json +++ b/packages/ui/component-reference.json @@ -7065,6 +7065,13 @@ "default": "\"\"", "options": [] }, + { + "name": "invalidMessage", + "type": "string", + "required": false, + "default": "\"\"", + "options": [] + }, { "name": "inline", "type": "boolean", diff --git a/packages/ui/components/StrongPassword.wrn b/packages/ui/components/StrongPassword.wrn index bb62eff1..0b0267d4 100644 --- a/packages/ui/components/StrongPassword.wrn +++ b/packages/ui/components/StrongPassword.wrn @@ -41,7 +41,16 @@ component StrongPassword { state detailsOpen: boolean = false state revealed: boolean = false + state nativeError: string = "" + functions { + client function handleInvalid(sourceEvent) { + sourceEvent.preventDefault() + sourceEvent.stopPropagation() + nativeError = validationMessage || (sourceEvent.currentTarget.validity.valueMissing ? "Enter your password." : "Enter a password that meets the requirements.") + sourceEvent.currentTarget.setAttribute("aria-invalid", "true") + } + shared function hasMinimumLength() { return password.length >= Number(minLength) } @@ -146,11 +155,11 @@ component StrongPassword { disabled="{disabled}" readonly="{readonly}" required="{required}" - aria-invalid="{invalid ? 'true' : 'false'}" - aria-describedby="{validationMessage ? name + '-validation' : hintText ? name + '-hint' : ''}" + aria-invalid="{invalid || nativeError ? 'true' : 'false'}" + aria-describedby="{(validationMessage || nativeError) ? name + '-validation' : hintText ? name + '-hint' : ''}" @focus="detailsOpen = true" @blur="detailsOpen = false" - @input="password = event.target.value; output.input({ value: password, score: score(), maximumScore: maximumScore(), percent: strengthPercent(), level: strengthLevel() }); output.strength({ value: password, score: score(), maximumScore: maximumScore(), percent: strengthPercent(), level: strengthLevel() })" + @invalid="handleInvalid(event)" @input="nativeError = ''; password = event.target.value; output.input({ value: password, score: score(), maximumScore: maximumScore(), percent: strengthPercent(), level: strengthLevel() }); output.strength({ value: password, score: score(), maximumScore: maximumScore(), percent: strengthPercent(), level: strengthLevel() })" @change="output.change({ value: password, score: score(), maximumScore: maximumScore(), percent: strengthPercent(), level: strengthLevel() })" /> {/if} - {validationMessage} + {(validationMessage || nativeError)} } diff --git a/packages/ui/components/TogglePassword.wrn b/packages/ui/components/TogglePassword.wrn index 182e1aeb..76bac246 100644 --- a/packages/ui/components/TogglePassword.wrn +++ b/packages/ui/components/TogglePassword.wrn @@ -36,7 +36,16 @@ component TogglePassword { state revealed = visible + state nativeError: string = "" + functions { + client function handleInvalid(sourceEvent) { + sourceEvent.preventDefault() + sourceEvent.stopPropagation() + nativeError = validationMessage || (sourceEvent.currentTarget.validity.valueMissing ? "Enter your password." : "Enter a password that meets the requirements.") + sourceEvent.currentTarget.setAttribute("aria-invalid", "true") + } + shared function toggleVisibility() { if (disabled || readonly || !toggleable) { return @@ -78,8 +87,8 @@ component TogglePassword { disabled="{disabled || field.disabled}" readonly="{readonly || field.readonly}" required="{required || field.required}" - aria-invalid="{invalid ? 'true' : 'false'}" - @input="output.input({ name: event.target.name, value: event.target.value, visible: revealed })" + aria-invalid="{invalid || nativeError ? 'true' : 'false'}" + @invalid="handleInvalid(event)" @input="nativeError = ''; output.input({ name: event.target.name, value: event.target.value, visible: revealed })" @change="output.change({ name: event.target.name, value: event.target.value, visible: revealed })" /> {#if toggleable && toggleMode === "button"} @@ -122,9 +131,9 @@ component TogglePassword { disabled="{disabled}" readonly="{readonly}" required="{required}" - aria-invalid="{invalid ? 'true' : 'false'}" - aria-describedby="{validationMessage ? name + '-validation' : helpText ? name + '-help' : ''}" - @input="output.input({ value: event.target.value, visible: revealed })" + aria-invalid="{invalid || nativeError ? 'true' : 'false'}" + aria-describedby="{(validationMessage || nativeError) ? name + '-validation' : helpText ? name + '-help' : ''}" + @invalid="handleInvalid(event)" @input="nativeError = ''; output.input({ value: event.target.value, visible: revealed })" @change="output.change({ value: event.target.value, visible: revealed })" /> {#if toggleable && toggleMode === "button"} @@ -153,12 +162,12 @@ component TogglePassword { {checkboxLabel} {/if} - {#if helpText && !validationMessage}{helpText}{/if} + {#if helpText && !(validationMessage || nativeError)}{helpText}{/if} {validationMessage} + >{(validationMessage || nativeError)} } diff --git a/packages/ui/components/input.wrn b/packages/ui/components/input.wrn index 758ea822..b02f2803 100644 --- a/packages/ui/components/input.wrn +++ b/packages/ui/components/input.wrn @@ -25,6 +25,7 @@ component Input { helperText: string = "" cornerHint: string = "" error: string = "" + invalidMessage: string = "" inline: boolean = false readonly: boolean = false disabled: boolean = false @@ -56,7 +57,16 @@ component Input { client function handleChange(sourceEvent) { sourceEvent.stopPropagation(); output.change(detail(sourceEvent)) } client function handleFocus(sourceEvent) { sourceEvent.stopPropagation(); output.focus(detail(sourceEvent)) } client function handleBlur(sourceEvent) { sourceEvent.stopPropagation(); output.blur(detail(sourceEvent)) } - client function handleInvalid(sourceEvent) { sourceEvent.stopPropagation(); validationError = sourceEvent.currentTarget.validationMessage; sourceEvent.currentTarget.setAttribute("aria-invalid", "true"); sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", "true"); output.invalid({ value: sourceEvent.currentTarget.value, name: name, message: validationError, sourceEvent: sourceEvent }) } + client function handleInvalid(sourceEvent) { + sourceEvent.preventDefault() + sourceEvent.stopPropagation() + const field = sourceEvent.currentTarget + const validity = field.validity + validationError = error || invalidMessage || (validity.valueMissing ? "This field is required." : validity.typeMismatch ? "Enter a valid " + (type === "email" ? "email address." : "value.") : validity.tooShort ? "Enter at least " + minlength + " characters." : validity.tooLong ? "Enter no more than " + maxlength + " characters." : validity.patternMismatch ? "Enter a value in the required format." : validity.rangeUnderflow ? "Enter a value of at least " + min + "." : validity.rangeOverflow ? "Enter a value no greater than " + max + "." : "Enter a valid value.") + field.setAttribute("aria-invalid", "true") + field.closest(".wrn-next--field").setAttribute("data-invalid", "true") + output.invalid({ value: field.value, name: name, message: validationError, sourceEvent: sourceEvent }) + } client function handleKeydown(sourceEvent) { sourceEvent.stopPropagation(); output.keydown({ key: sourceEvent.key, value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent }) } client function handleKeyup(sourceEvent) { sourceEvent.stopPropagation(); output.keyup({ key: sourceEvent.key, value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent }) } } diff --git a/packages/ui/components/select.wrn b/packages/ui/components/select.wrn index 0f25973b..b53ebcda 100644 --- a/packages/ui/components/select.wrn +++ b/packages/ui/components/select.wrn @@ -46,7 +46,7 @@ size: string = "default" } client function handleFocus(sourceEvent) { emitField("focus", sourceEvent); output.open({ name: name, sourceEvent: sourceEvent }) } client function handleBlur(sourceEvent) { emitField("blur", sourceEvent); output.close({ name: name, sourceEvent: sourceEvent }) } - client function handleInvalid(sourceEvent) { validationError = sourceEvent.currentTarget.validationMessage; sourceEvent.currentTarget.setAttribute("aria-invalid", "true"); sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", "true"); output.invalid({ name: name, message: validationError, sourceEvent: sourceEvent }) } + client function handleInvalid(sourceEvent) { sourceEvent.preventDefault(); sourceEvent.stopPropagation(); validationError = error || "Choose an option."; sourceEvent.currentTarget.setAttribute("aria-invalid", "true"); sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", "true"); output.invalid({ name: name, message: validationError, sourceEvent: sourceEvent }) } } view { diff --git a/packages/ui/components/textarea.wrn b/packages/ui/components/textarea.wrn index 99e2db43..5df54e7f 100644 --- a/packages/ui/components/textarea.wrn +++ b/packages/ui/components/textarea.wrn @@ -40,7 +40,9 @@ size: string = "default" output[nameEvent]({ value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent }) } client function handleInvalid(sourceEvent) { - validationError = sourceEvent.currentTarget.validationMessage + sourceEvent.preventDefault() + sourceEvent.stopPropagation() + validationError = error || (sourceEvent.currentTarget.validity.valueMissing ? "This field is required." : "Enter text within the allowed length.") sourceEvent.currentTarget.setAttribute("aria-invalid", "true") sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", "true") output.invalid({ value: sourceEvent.currentTarget.value, name: name, message: validationError, sourceEvent: sourceEvent }) diff --git a/packages/ui/package.json b/packages/ui/package.json index c215e247..9457014d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ui", - "version": "0.8.57", + "version": "0.8.58", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/ui/test/ui.test.ts b/packages/ui/test/ui.test.ts index 2bdcddcd..01a04430 100644 --- a/packages/ui/test/ui.test.ts +++ b/packages/ui/test/ui.test.ts @@ -1344,7 +1344,7 @@ test("basic form fields expose shared labels, variants, states, hints, values, a expect(emitted).toEqual(["input", "change", "focus", "blur", "keydown", "keyup"]); }); -test("basic fields render native constraint messages and select matches field surfaces", async () => { +test("basic fields suppress native popups and render package constraint messages", async () => { const inputSource = readFileSync(uiComponentPath("Input"), "utf8"); const dom = mountHtml( await renderComponent(inputSource, { @@ -1355,9 +1355,14 @@ test("basic fields render native constraint messages and select matches field su }), ); const input = dom.querySelector("input") as HTMLInputElement; - input.dispatchEvent(new (dom.window as any).Event("invalid", { bubbles: false })); + const invalidEvent = new (dom.window as any).Event("invalid", { + bubbles: false, + cancelable: true, + }); + input.dispatchEvent(invalidEvent); + expect(invalidEvent.defaultPrevented).toBe(true); expect(dom.querySelector("[data-error='name']")?.textContent?.trim()).toBe( - input.validationMessage, + "This field is required.", ); expect(dom.querySelector(".wrn-next--input")?.getAttribute("data-invalid")).toBe("true"); @@ -1367,6 +1372,46 @@ test("basic fields render native constraint messages and select matches field su expect(selectSource).toContain("wrn-next__select-indicator"); }); +test("OTP inputs accept six digits and show custom inline errors without native popups", async () => { + const source = readFileSync(uiComponentPath("Input"), "utf8"); + const dom = mountHtml( + await renderComponent(source, { + name: "code", + pattern: "[0-9]{6}", + required: true, + invalidMessage: "Enter the six-digit verification code.", + }), + ); + const input = dom.querySelector("input") as HTMLInputElement; + input.value = "737651"; + expect(input.checkValidity()).toBe(true); + input.value = "123"; + const event = new (dom.window as any).Event("invalid", { cancelable: true }); + input.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + expect(dom.querySelector("[data-error='code']")?.textContent).toContain( + "Enter the six-digit verification code.", + ); +}); + +test("password fields cancel native invalid popups and show inline errors", async () => { + for (const component of ["TogglePassword", "StrongPassword"]) { + const dom = mountHtml( + await renderComponent(readFileSync(uiComponentPath(component), "utf8"), { + name: "password", + required: true, + }), + ); + const input = dom.querySelector("input") as HTMLInputElement; + const event = new (dom.window as any).Event("invalid", { cancelable: true }); + input.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + expect(dom.querySelector("[data-error='password']")?.textContent).toContain( + "Enter your password.", + ); + } +}); + test("all basic form components render values and their must-have interaction events", async () => { const components = [ "Checkbox",