Skip to content

Commit

Permalink
feat: add constant validation display mode
Browse files Browse the repository at this point in the history
In this mode, the validation criteria are shown from the beginning. As soon as a criterion is met, it will turn green. This way, the user gets a direct feedback.
  • Loading branch information
Slartibartfass2 committed Dec 17, 2024
1 parent d3a48bc commit f3a0592
Show file tree
Hide file tree
Showing 7 changed files with 190 additions and 50 deletions.
99 changes: 92 additions & 7 deletions src/lib/components/composites/input/Input.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
import { Label } from "$lib/components/primitives/label/index.js";
import { cn } from "$lib/utils";
import type { WithElementRef } from "bits-ui";
import type { Snippet } from "svelte";
import { onMount, type Snippet } from "svelte";
import type {
HTMLButtonAttributes,
HTMLInputAttributes,
HTMLInputTypeAttribute,
} from "svelte/elements";
import { z } from "zod";
import InputValidationCriterion from "./InputValidationCriterion.svelte";
type Props = WithElementRef<HTMLInputAttributes> & {
inputId: string;
Expand All @@ -23,6 +24,8 @@
onButtonClick?: () => void;
buttonContent?: Snippet;
buttonProps?: HTMLButtonAttributes;
errorMessagePrefix?: string;
validationDisplayMode?: "constant" | "onError";
};
let {
Expand All @@ -37,12 +40,58 @@
onButtonClick,
buttonContent,
buttonProps,
errorMessagePrefix = "",
validationDisplayMode = "onError",
value = $bindable(),
class: className,
...restProps
}: Props = $props();
let errorMessages = $state<string[]>([]);
interface ValidationCriterion {
code: string;
subCode?: string;
isMet: boolean;
message: string;
}
let validationCriteria: ValidationCriterion[] = $state([]);
/**
* Initialize the validation criteria with an empty input.
* This dynamically creates the validation criteria based on the schema.
*/
function initValidationCriteria() {
if (!schema || validationDisplayMode === "onError") {
return;
}
const parsedSchema = schema.safeParse("");
validationCriteria = errorsToCriteria(parsedSchema.error?.errors ?? []);
}
function errorsToCriteria(errors: z.ZodIssue[]): ValidationCriterion[] {
return errors.map((error) => {
const code = error.code;
const subCode =
"params" in error && error.params !== undefined
? error.params["subCode"]
: undefined;
return { code, subCode, isMet: false, message: error.message };
});
}
function doesIssueMatchCriterion(criterion: ValidationCriterion, issue: z.ZodIssue): boolean {
if (criterion.code !== issue.code) {
return false;
}
let subCode: string | undefined;
if ("params" in issue && issue.params !== undefined) {
subCode = issue.params["subCode"];
}
return criterion.subCode === subCode;
}
let isFirstValidation = $state(true);
/**
Expand All @@ -59,7 +108,20 @@
isFirstValidation = false;
const parsedSchema = schema.safeParse(value);
errorMessages = parsedSchema.error?.errors.map((error) => error.message) ?? [];
// Either update the criteria or recreate them based on the validation display mode
if (validationDisplayMode === "constant") {
validationCriteria = validationCriteria.map((criterion) => {
// If no error is found, the criterion is met
const isMet =
parsedSchema.error?.errors.find((error) =>
doesIssueMatchCriterion(criterion, error),
) === undefined;
return { ...criterion, isMet };
});
} else {
validationCriteria = errorsToCriteria(parsedSchema.error?.errors ?? []);
}
return parsedSchema.success;
}
Expand All @@ -74,10 +136,14 @@
* After the initial input was validated, validate the input on every input event.
*/
function onInput() {
if (!isFirstValidation) {
if (!isFirstValidation || validationDisplayMode === "constant") {
validate();
}
}
onMount(() => {
initValidationCriteria();
});
</script>

<!--
Expand All @@ -102,6 +168,8 @@ Usage:
required
type="text"
schema={myExampleSchema}
errorMessagePrefix="Example must contain"
validationDisplayMode="constant"
bind:this={exampleInput}
bind:value={exampleValue}
/>
Expand Down Expand Up @@ -133,7 +201,24 @@ Usage:
</button>
{/if}
</div>
{#each errorMessages as errorMessage}
<p class="text-sm text-red-500">{errorMessage}</p>
{/each}
<!-- Display error messages either as constant checks or as list while on validation error -->
{#if validationDisplayMode === "onError"}
{#each validationCriteria.filter((criterion) => !criterion.isMet) as criterion}
<p class="text-sm text-red-500" data-testid="error-message">
{errorMessagePrefix}
{criterion.message}
</p>
{/each}
{:else}
<div class="grid grid-cols-1 sm:grid-cols-2">
{#each validationCriteria as criterion, i}
<InputValidationCriterion
class={i === validationCriteria.length - 1 ? "col-span-2" : ""}
isValid={criterion.isMet}
description={criterion.message}
data-testid="validation-criterion"
/>
{/each}
</div>
{/if}
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<script lang="ts">
import { cn } from "$lib/utils";
import CircleCheck from "lucide-svelte/icons/circle-check";
import CircleAlert from "lucide-svelte/icons/circle-alert";
import type { WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
type Props = WithElementRef<HTMLAttributes<HTMLDivElement>> & {
isValid: boolean;
description: string;
};
let { isValid, description, class: className, ...restProps }: Props = $props();
const prettyDescription = description.substring(0, 1).toUpperCase() + description.substring(1);
</script>

<!--
@component
Input Validation Criterion Element used to display the validation status of an input.
Usage:
```svelte
<InputValidationCriterion {isValid} description="At least one character" />
```
-->
<div
class={cn(
"flex flex-row gap-2 w-full px-0.5 py-0.5 text-sm items-start",
isValid ? "text-green-500" : "text-red-500",
className,
)}
{...restProps}
>
<div>
{#if isValid}
<CircleCheck class="w-5 h-5" data-testid="validation-success" />
{:else}
<CircleAlert class="w-5 h-5" data-testid="validation-fail" />
{/if}
</div>
<p class="wrap">{prettyDescription}</p>
</div>
2 changes: 2 additions & 0 deletions src/lib/components/composites/input/PasswordInput.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ Usage:
schema={Schema.password}
onButtonClick={() => (isPasswordVisible = !isPasswordVisible)}
buttonProps={{ "aria-label": "Toggle password visibility" }}
errorMessagePrefix="Password must contain"
validationDisplayMode="constant"
bind:value
bind:this={input}
autocomplete={undefined}
Expand Down
39 changes: 20 additions & 19 deletions src/lib/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,29 +35,34 @@ function addCustomIssue(context: z.RefinementCtx, subCode: ZodIssueSubCode, mess
const firsNameSchema = z
.string()
.trim()
.min(1, { message: "First Name must contain at least 1 non-whitespace character" })
.max(100, { message: "First Name must contain at most 100 non-whitespace characters" });
.min(1, { message: "at least 1 non-whitespace character" })
.max(100, { message: "at most 100 non-whitespace characters" });

/**
* Schema for the last name of a user.
*/
const lastNameSchema = z
.string()
.trim()
.min(1, { message: "Last Name must contain at least 1 non-whitespace character" })
.max(100, { message: "Last Name must contain at most 100 non-whitespace characters" });
.min(1, { message: "at least 1 non-whitespace character" })
.max(100, { message: "at most 100 non-whitespace characters" });

/**
* Schema for the email of a user.
*/
const emailSchema = z.string().email({ message: "Email must have valid format" });
const emailSchema = z.string().email({ message: "a valid format" });

const upperCaseLetters = "A-Z";
const lowerCaseLetters = "a-z";
const numbers = "0-9";
const specialCharacters = "#$%&@^`~.,:;\"'\\\\/|_\\-<>*+!?={[()\\]}";
const specialCharacters = "#$%&@^`~.,:;\"'\\/|_-<>*+!?={[()]}";
// Keep specialCharacters to display, but escape characters for regex
const specialCharactersRegex = specialCharacters
.replace("/", "\\/")
.replace("-", "\\-")
.replace("]", "\\]");
const passwordRegex = new RegExp(
`^[${upperCaseLetters}${lowerCaseLetters}${numbers}${specialCharacters}]*$`,
`^[${upperCaseLetters}${lowerCaseLetters}${numbers}${specialCharactersRegex}]*$`,
);
function hasMinNumberOfCharacterSet(password: string, characterSet: string, minNumber: number) {
const regExp = new RegExp(`[${characterSet}]`, "g");
Expand All @@ -69,46 +74,42 @@ function hasMinNumberOfCharacterSet(password: string, characterSet: string, minN
*/
const passwordSchema = z
.string()
.min(8, { message: "Password must contain at least 8 characters" })
.max(128, { message: "Password must contain at most 128 characters" })
.min(8, { message: "at least 8 characters" })
.max(128, { message: "at most 128 characters" })
.superRefine((password, context) => {
if (!passwordRegex.test(password)) {
addCustomIssue(
context,
ZodIssueSubCode.invalid_characters,
`Password must contain only lower or upper case letters, numbers and the following special characters ${specialCharacters}`,
`only lower or upper case letters, numbers and the following special characters ${specialCharacters}`,
);
}

if (!hasMinNumberOfCharacterSet(password, upperCaseLetters, 2)) {
addCustomIssue(
context,
ZodIssueSubCode.not_enough_upper_case_letters,
"Password must contain at least 2 upper case letter",
"at least 2 upper case letter",
);
}

if (!hasMinNumberOfCharacterSet(password, lowerCaseLetters, 2)) {
addCustomIssue(
context,
ZodIssueSubCode.not_enough_lower_case_letters,
"Password must contain at least 2 lower case letter",
"at least 2 lower case letter",
);
}

if (!hasMinNumberOfCharacterSet(password, numbers, 2)) {
addCustomIssue(
context,
ZodIssueSubCode.not_enough_numbers,
"Password must contain at least 2 numbers",
);
addCustomIssue(context, ZodIssueSubCode.not_enough_numbers, "at least 2 numbers");
}

if (!hasMinNumberOfCharacterSet(password, specialCharacters, 2)) {
if (!hasMinNumberOfCharacterSet(password, specialCharactersRegex, 2)) {
addCustomIssue(
context,
ZodIssueSubCode.not_enough_special_characters,
`Password must contain at least 2 special characters (${specialCharacters})`,
`at least 2 special characters (${specialCharacters})`,
);
}
});
Expand Down
3 changes: 3 additions & 0 deletions src/routes/(auth)/signup/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
required
type="text"
schema={Schema.firstName}
errorMessagePrefix="First name must contain"
bind:this={firstNameInput}
/>
<Input
Expand All @@ -66,6 +67,7 @@
required
type="text"
schema={Schema.lastName}
errorMessagePrefix="Last name must contain"
bind:this={lastNameInput}
/>
</div>
Expand All @@ -77,6 +79,7 @@
required
type="email"
schema={Schema.email}
errorMessagePrefix="Email must have"
bind:this={emailInput}
/>
<PasswordInput class="w-full" bind:this={passwordInput} />
Expand Down
20 changes: 10 additions & 10 deletions tests/integration/input/input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import ExampleInput from "./ExampleInput.svelte";

describe("Input", () => {
test("When props are provided, then label, link and input is shown", () => {
const input = render(Input, {
const { component } = render(Input, {
target: document.body,
props: {
inputId: "input",
Expand Down Expand Up @@ -43,18 +43,18 @@ describe("Input", () => {
expect(inputElement).toHaveValue("Example");

// Error messages do not exist
let errorMessages = document.getElementsByTagName("p");
let errorMessages = screen.queryAllByTestId("error-message");
expect(errorMessages).toHaveLength(0);

// When input is validated, then no error messages are shown
expect(input.component.validate()).toBe(true);
errorMessages = document.getElementsByTagName("p");
expect(component.validate()).toBe(true);
errorMessages = screen.queryAllByTestId("error-message");
expect(errorMessages).toHaveLength(0);
expect(input.component.getValue()).toBe("Example");
expect(component.getValue()).toBe("Example");
});

test("When props are provided and input is invalid, then error messages are shown", async () => {
const input = render(Input, {
const { component } = render(Input, {
target: document.body,
props: {
inputId: "input",
Expand All @@ -72,14 +72,14 @@ describe("Input", () => {
});

// Error messages do not exist
let errorMessages = document.getElementsByTagName("p");
let errorMessages = screen.queryAllByTestId("error-message");
expect(errorMessages).toHaveLength(0);

// When input is validated, then error messages are shown
expect(input.component.validate()).toBe(false);
expect(component.validate()).toBe(false);
await waitFor(() => {
errorMessages = document.getElementsByTagName("p");
expect(errorMessages).toHaveLength(1);
errorMessages = screen.queryAllByTestId("error-message");
expect(errorMessages.length).toBeGreaterThan(0);
});
expect(errorMessages[0]).toHaveTextContent("String must contain at least 10 character(s)");
});
Expand Down
Loading

0 comments on commit f3a0592

Please sign in to comment.