Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/nine-lights-find.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@tapsioss/web-components": patch
---

Resolve issues with enforcing minimum and maximum input length in text input components.

1 change: 1 addition & 0 deletions packages/web-components/src/internals/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./animations.ts";
export * from "./keyboard.ts";
export * from "./tokens.ts";
export * from "./validation.ts";
33 changes: 33 additions & 0 deletions packages/web-components/src/internals/validation.ts
Original file line number Diff line number Diff line change
@@ -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).`;
};
51 changes: 30 additions & 21 deletions packages/web-components/src/text-area/Validator.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {
getTooLongValidationMessage,
getTooShortValidationMessage,
} from "../internals/index.ts";
import { Validator, type ValidityAndMessage } from "../utils/index.ts";

type State = {
Expand Down Expand Up @@ -70,32 +74,37 @@ class TextAreaValidator extends Validator<TextAreaState> {

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,
};
}

Expand Down
93 changes: 93 additions & 0 deletions packages/web-components/src/text-area/text-area.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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,
`
<form data-testid="form">
<tapsi-text-area
name="username"
label="Username"
data-testid="field"
minlength="2"
maxlength="5"
></tapsi-text-area>
</form>
`,
);

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,
`
<form data-testid="form">
<tapsi-text-area
name="username"
label="Username"
data-testid="field"
minlength="5"
maxlength="50"
></tapsi-text-area>
</form>
`,
);

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);
});
});
51 changes: 30 additions & 21 deletions packages/web-components/src/text-field/Validator.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {
getTooLongValidationMessage,
getTooShortValidationMessage,
} from "../internals/index.ts";
import { Validator, type ValidityAndMessage } from "../utils/index.ts";

export type State = {
Expand Down Expand Up @@ -132,32 +136,37 @@ class TextFieldValidator extends Validator<TextFieldState> {
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,
};
}

Expand Down
93 changes: 93 additions & 0 deletions packages/web-components/src/text-field/text-field.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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,
`
<form data-testid="form">
<tapsi-text-field
name="username"
label="Username"
data-testid="field"
minlength="2"
maxlength="5"
></tapsi-text-field>
</form>
`,
);

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,
`
<form data-testid="form">
<tapsi-text-field
name="username"
label="Username"
data-testid="field"
minlength="5"
maxlength="50"
></tapsi-text-field>
</form>
`,
);

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,
}) => {
Expand Down