diff --git a/.changeset/nine-lights-find.md b/.changeset/nine-lights-find.md new file mode 100644 index 00000000..a4513c08 --- /dev/null +++ b/.changeset/nine-lights-find.md @@ -0,0 +1,6 @@ +--- +"@tapsioss/web-components": patch +--- + +Resolve issues with enforcing minimum and maximum input length in text input components. + \ No newline at end of file diff --git a/packages/web-components/src/internals/index.ts b/packages/web-components/src/internals/index.ts index 4dff1b42..50edbede 100644 --- a/packages/web-components/src/internals/index.ts +++ b/packages/web-components/src/internals/index.ts @@ -1,3 +1,4 @@ export * from "./animations.ts"; export * from "./keyboard.ts"; export * from "./tokens.ts"; +export * from "./validation.ts"; diff --git a/packages/web-components/src/internals/validation.ts b/packages/web-components/src/internals/validation.ts new file mode 100644 index 00000000..c9465ed7 --- /dev/null +++ b/packages/web-components/src/internals/validation.ts @@ -0,0 +1,33 @@ +/** + * Builds a message indicating that the current text exceeds the allowed length. + * + * @param {Object} params + * @param {number} params.currentLength Number of characters currently used. + * @param {number} params.maxLength Maximum allowed number of characters. + * @returns {string} A user-facing message describing how many characters must be trimmed. + */ +export const getTooLongValidationMessage = (params: { + currentLength: number; + maxLength: number; +}): string => { + const { currentLength, maxLength } = params; + + return `Please shorten this text to ${maxLength} characters or less (you are currently using ${currentLength} characters).`; +}; + +/** + * Builds a message indicating that the current text is shorter than the required minimum. + * + * @param {Object} params + * @param {number} params.currentLength Number of characters currently used. + * @param {number} params.minLength Minimum required number of characters. + * @returns {string} A user-facing message explaining how many more characters are needed. + */ +export const getTooShortValidationMessage = (params: { + currentLength: number; + minLength: number; +}): string => { + const { currentLength, minLength } = params; + + return `Please use at least ${minLength} characters (you are currently using ${currentLength} characters).`; +}; diff --git a/packages/web-components/src/text-area/Validator.ts b/packages/web-components/src/text-area/Validator.ts index 7305ec90..200a12c4 100644 --- a/packages/web-components/src/text-area/Validator.ts +++ b/packages/web-components/src/text-area/Validator.ts @@ -1,3 +1,7 @@ +import { + getTooLongValidationMessage, + getTooShortValidationMessage, +} from "../internals/index.ts"; import { Validator, type ValidityAndMessage } from "../utils/index.ts"; type State = { @@ -70,32 +74,37 @@ class TextAreaValidator extends Validator { input.required = state.required; - // Use -1 to represent no minlength and maxlength, which is what the - // platform input returns. However, it will throw an error if you try to - // manually set it to -1. - // - // While the type is `number`, it may actually be `null` at runtime. - // `null > -1` is true since `null` coerces to `0`, so we default null and - // undefined to -1. - // - // We set attributes instead of properties since setting a property may - // throw an out of bounds error in relation to the other property. - // Attributes will not throw errors while the state is updating. - if ((state.minLength ?? -1) > -1) { - input.setAttribute("minlength", String(state.minLength)); - } else { - input.removeAttribute("minlength"); + let tooShort = false; + let tooLong = false; + let validationMessage; + + const currentLength = input.value.length; + const minLength = state.minLength ?? -1; + const maxLength = state.maxLength ?? -1; + + // Custom minlength validation: the browser's native minlength check may not trigger + // correctly in all scenarios, so we enforce it manually here. + if (currentLength < minLength && minLength > -1) { + tooShort = true; + validationMessage = getTooShortValidationMessage({ + currentLength, + minLength, + }); } - if ((state.maxLength ?? -1) > -1) { - input.setAttribute("maxlength", String(state.maxLength)); - } else { - input.removeAttribute("maxlength"); + // Custom maxlength validation: the browser's native maxlength check may not trigger + // correctly in all scenarios, so we enforce it manually here. + if (currentLength > maxLength && maxLength > -1) { + tooLong = true; + validationMessage = getTooLongValidationMessage({ + currentLength, + maxLength, + }); } return { - validity: input.validity, - validationMessage: input.validationMessage, + validity: { ...input.validity, tooLong, tooShort }, + validationMessage: validationMessage ?? input.validationMessage, }; } diff --git a/packages/web-components/src/text-area/text-area.test.ts b/packages/web-components/src/text-area/text-area.test.ts index 614aacf3..2125d684 100644 --- a/packages/web-components/src/text-area/text-area.test.ts +++ b/packages/web-components/src/text-area/text-area.test.ts @@ -11,6 +11,10 @@ import { ErrorMessages as BaseErrorMessages, Slots, } from "../base-text-input/constants.ts"; +import { + getTooLongValidationMessage, + getTooShortValidationMessage, +} from "../internals/index.ts"; import { ErrorMessages } from "./constants.ts"; describe("🧩 text-area", () => { @@ -217,4 +221,93 @@ describe("🧩 text-area", () => { expect(msg).toBeDefined(); }); + + test("🧪 should show correct validation error when input is too long", async ({ + page, + }) => { + await render( + page, + ` +
+ +
+ `, + ); + + const formState = await page.evaluate(() => { + const form = document.querySelector( + '[data-testid="form"]', + ) as HTMLFormElement; + + const el = form.elements.namedItem("username") as HTMLInputElement; + + el.value = "1234567"; + + return { + valid: el.checkValidity(), + message: el.validationMessage, + currentLength: el.value.length, + }; + }); + + const expectedMessage = getTooLongValidationMessage({ + currentLength: formState.currentLength, + maxLength: 5, + }); + + expect(formState.valid).toBe(false); + expect(formState.message).toBe(expectedMessage); + }); + + test("🧪 should show form validation error when input is too short", async ({ + page, + }) => { + await render( + page, + ` +
+ +
+ `, + ); + + const field = page.getByTestId("field"); + + await field.click(); + await page.keyboard.type("hi"); + + const formState = await page.evaluate(() => { + const form = document.querySelector( + '[data-testid="form"]', + ) as HTMLFormElement; + + const el = form.elements.namedItem("username") as HTMLInputElement; + + return { + message: el.validationMessage, + valid: el.checkValidity(), + currentLength: el.value.length, + }; + }); + + const expectedMessage = getTooShortValidationMessage({ + currentLength: formState.currentLength, + minLength: 5, + }); + + expect(formState.valid).toBe(false); + expect(formState.message).toBe(expectedMessage); + }); }); diff --git a/packages/web-components/src/text-field/Validator.ts b/packages/web-components/src/text-field/Validator.ts index 9c225bc1..97c50036 100644 --- a/packages/web-components/src/text-field/Validator.ts +++ b/packages/web-components/src/text-field/Validator.ts @@ -1,3 +1,7 @@ +import { + getTooLongValidationMessage, + getTooShortValidationMessage, +} from "../internals/index.ts"; import { Validator, type ValidityAndMessage } from "../utils/index.ts"; export type State = { @@ -132,32 +136,37 @@ class TextFieldValidator extends Validator { input.removeAttribute("step"); } - // Use -1 to represent no minlength and maxlength, which is what the - // platform input returns. However, it will throw an error if you try to - // manually set it to -1. - // - // While the type is `number`, it may actually be `null` at runtime. - // `null > -1` is true since `null` coerces to `0`, so we default null and - // undefined to -1. - // - // We set attributes instead of properties since setting a property may - // throw an out of bounds error in relation to the other property. - // Attributes will not throw errors while the state is updating. - if ((state.minLength ?? -1) > -1) { - input.setAttribute("minlength", String(state.minLength)); - } else { - input.removeAttribute("minlength"); + let tooShort = false; + let tooLong = false; + let validationMessage; + + const currentLength = input.value.length; + const minLength = state.minLength ?? -1; + const maxLength = state.maxLength ?? -1; + + // Custom minlength validation: the browser's native minlength check may not trigger + // correctly in all scenarios, so we enforce it manually here. + if (currentLength < minLength && minLength > -1) { + tooShort = true; + validationMessage = getTooShortValidationMessage({ + currentLength, + minLength, + }); } - if ((state.maxLength ?? -1) > -1) { - input.setAttribute("maxlength", String(state.maxLength)); - } else { - input.removeAttribute("maxlength"); + // Custom maxlength validation: the browser's native maxlength check may not trigger + // correctly in all scenarios, so we enforce it manually here. + if (currentLength > maxLength && maxLength > -1) { + tooLong = true; + validationMessage = getTooLongValidationMessage({ + currentLength, + maxLength, + }); } return { - validity: input.validity, - validationMessage: input.validationMessage, + validity: { ...input.validity, tooLong, tooShort }, + validationMessage: validationMessage ?? input.validationMessage, }; } diff --git a/packages/web-components/src/text-field/text-field.test.ts b/packages/web-components/src/text-field/text-field.test.ts index 87a899cf..7f7793b6 100644 --- a/packages/web-components/src/text-field/text-field.test.ts +++ b/packages/web-components/src/text-field/text-field.test.ts @@ -9,6 +9,10 @@ import { test, } from "@internals/test-helpers"; import { ErrorMessages, Slots } from "../base-text-input/constants.ts"; +import { + getTooLongValidationMessage, + getTooShortValidationMessage, +} from "../internals/index.ts"; import { type TextField } from "./index.ts"; describe("🧩 text-field", () => { @@ -243,6 +247,95 @@ describe("🧩 text-field", () => { await expect(input).toBeFocused(); }); + test("🧪 should show correct validation error when input is too long", async ({ + page, + }) => { + await render( + page, + ` +
+ +
+ `, + ); + + const formState = await page.evaluate(() => { + const form = document.querySelector( + '[data-testid="form"]', + ) as HTMLFormElement; + + const el = form.elements.namedItem("username") as HTMLInputElement; + + el.value = "1234567"; + + return { + valid: el.checkValidity(), + message: el.validationMessage, + currentLength: el.value.length, + }; + }); + + const expectedMessage = getTooLongValidationMessage({ + currentLength: formState.currentLength, + maxLength: 5, + }); + + expect(formState.valid).toBe(false); + expect(formState.message).toBe(expectedMessage); + }); + + test("🧪 should show form validation error when input is too short", async ({ + page, + }) => { + await render( + page, + ` +
+ +
+ `, + ); + + const field = page.getByTestId("field"); + + await field.click(); + await page.keyboard.type("hi"); + + const formState = await page.evaluate(() => { + const form = document.querySelector( + '[data-testid="form"]', + ) as HTMLFormElement; + + const el = form.elements.namedItem("username") as HTMLInputElement; + + return { + message: el.validationMessage, + valid: el.checkValidity(), + currentLength: el.value.length, + }; + }); + + const expectedMessage = getTooShortValidationMessage({ + currentLength: formState.currentLength, + minLength: 5, + }); + + expect(formState.valid).toBe(false); + expect(formState.message).toBe(expectedMessage); + }); + test("🦯 should be accessible inside a form with a valid label", async ({ page, }) => {