Compare commits

..
7 Commits
11 changed files with 3025 additions and 13 deletions
+3
View File
@@ -22,3 +22,6 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
*storybook.log
storybook-static
+17
View File
@@ -0,0 +1,17 @@
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
"stories": [
"../src/**/*.mdx",
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
],
"addons": [
"@chromatic-com/storybook",
"@storybook/addon-vitest",
"@storybook/addon-a11y",
"@storybook/addon-docs",
"@storybook/addon-mcp"
],
"framework": "@storybook/react-vite"
};
export default config;
+21
View File
@@ -0,0 +1,21 @@
import type { Preview } from '@storybook/react-vite'
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
a11y: {
// 'todo' - show a11y violations in the test UI only
// 'error' - fail CI on a11y violations
// 'off' - skip a11y checks entirely
test: 'todo'
}
},
};
export default preview;
+15 -2
View File
@@ -7,7 +7,9 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "oxlint", "lint": "oxlint",
"preview": "vite preview" "preview": "vite preview",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-devtools": "^0.10.12", "@tanstack/react-devtools": "^0.10.12",
@@ -23,6 +25,17 @@
"@vitejs/plugin-react": "^6.1.1", "@vitejs/plugin-react": "^6.1.1",
"oxlint": "^1.81.0", "oxlint": "^1.81.0",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vite": "^8.3.0" "vite": "^8.3.0",
"storybook": "^10.6.0",
"@storybook/react-vite": "^10.6.0",
"@chromatic-com/storybook": "latest",
"@storybook/addon-vitest": "^10.6.0",
"@storybook/addon-a11y": "10.6.0",
"@storybook/addon-docs": "^10.6.0",
"@storybook/addon-mcp": "^10.6.0",
"vitest": "latest",
"playwright": "latest",
"@vitest/browser-playwright": "latest",
"@vitest/coverage-v8": "latest"
} }
} }
+2619 -5
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: false
+3 -3
View File
@@ -1,11 +1,11 @@
import "./App.css"; import "./App.css";
import { MyForm } from "./MyForm";
function App() { function App() {
return ( return (
<> <>
<section> <MyForm></MyForm>
<text>TanStackForm</text>
</section>
</> </>
); );
} }
+192
View File
@@ -0,0 +1,192 @@
import { useForm } from "@tanstack/react-form";
interface MyFormProps {
onSubmit?: (values: {
username?: string;
age?: string;
password?: string;
}) => void;
}
export function MyForm({ onSubmit }: MyFormProps) {
const form = useForm({
defaultValues: {
username: "",
age: "",
password: "",
},
onSubmit: async ({ value }) => {
onSubmit?.(value);
},
});
return (
<div
style={{
maxWidth: "400px",
margin: "20px auto",
fontFamily: "sans-serif",
}}
>
<h2>Registration Form (TanStack Form)</h2>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<form.Field
name="username"
validators={{
onChange: ({ value }) => {
if (!value) return "Full name is required";
if (!/^[a-zA-Za-z\s]+$/.test(value)) {
return "Please enter letters only";
}
return undefined;
},
}}
>
{(field) => (
<div style={{ marginBottom: "15px" }}>
<label
htmlFor={field.name}
style={{ display: "block", marginBottom: "5px" }}
>
Full Name (Letters only):
</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
style={{
width: "100%",
padding: "8px",
boxSizing: "border-box",
}}
/>
{field.state.meta.errors.length > 0 && (
<span style={{ color: "red", fontSize: "12px" }}>
{field.state.meta.errors.join(", ")}
</span>
)}
</div>
)}
</form.Field>
<form.Field
name="age"
validators={{
onChange: ({ value }) => {
if (!value) return "Age is required";
if (isNaN(Number(value))) {
return "Please enter numbers only";
}
if (Number(value) < 18) {
return "Age must be at least 18";
}
return undefined;
},
}}
>
{(field) => (
<div style={{ marginBottom: "15px" }}>
<label
htmlFor={field.name}
style={{ display: "block", marginBottom: "5px" }}
>
Age (Numbers only):
</label>
<input
id={field.name}
name={field.name}
type="number"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
style={{
width: "100%",
padding: "8px",
boxSizing: "border-box",
}}
/>
{field.state.meta.errors.length > 0 && (
<span style={{ color: "red", fontSize: "12px" }}>
{field.state.meta.errors.join(", ")}
</span>
)}
</div>
)}
</form.Field>
<form.Field
name="password"
validators={{
onChange: ({ value }) => {
if (!value) return "Password is required";
if (value.length < 8) {
return "Password must be at least 8 characters long";
}
return undefined;
},
}}
>
{(field) => (
<div style={{ marginBottom: "15px" }}>
<label
htmlFor={field.name}
style={{ display: "block", marginBottom: "5px" }}
>
Password:
</label>
<input
id={field.name}
name={field.name}
type="password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
style={{
width: "100%",
padding: "8px",
boxSizing: "border-box",
}}
/>
{field.state.meta.errors.length > 0 && (
<span style={{ color: "red", fontSize: "12px" }}>
{field.state.meta.errors.join(", ")}
</span>
)}
</div>
)}
</form.Field>
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
>
{([canSubmit, isSubmitting]) => (
<button
type="submit"
disabled={!canSubmit}
style={{
width: "100%",
padding: "10px",
backgroundColor: canSubmit ? "#007bff" : "#ccc",
color: "white",
border: "none",
borderRadius: "4px",
cursor: canSubmit ? "pointer" : "not-allowed",
}}
>
{isSubmitting ? "Submitting..." : "Sign Up"}
</button>
)}
</form.Subscribe>
</form>
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
import type { Meta, StoryObj } from "@storybook/react";
import { within, userEvent, expect, fn } from "storybook/test";
import { MyForm } from "../MyForm";
const meta: Meta<typeof MyForm> = {
title: "Components/MyForm",
component: MyForm,
parameters: {
layout: "centered",
},
args: {
onSubmit: fn(),
},
};
export default meta;
type Story = StoryObj<typeof MyForm>;
export const Default: Story = {};
export const ValidationErrors: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const nameInput = canvas.getByLabelText(/Full Name/i);
await userEvent.type(nameInput, "12345");
const ageInput = canvas.getByLabelText(/Age/i);
await userEvent.type(ageInput, "15");
const passwordInput = canvas.getByLabelText(/Password/i);
await userEvent.type(passwordInput, "123");
await userEvent.tab();
await expect(
canvas.getByText(/Please enter letters only/i),
).toBeInTheDocument();
await expect(
canvas.getByText(/Age must be at least 18/i),
).toBeInTheDocument();
await expect(
canvas.getByText(/Password must be at least 8 characters long/i),
).toBeInTheDocument();
},
};
export const SuccessfulSubmit: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const nameInput = canvas.getByLabelText(/Full Name/i);
await userEvent.type(nameInput, "Somayeh Ghanavati");
const ageInput = canvas.getByLabelText(/Age/i);
await userEvent.type(ageInput, "37");
const passwordInput = canvas.getByLabelText(/Password/i);
await userEvent.type(passwordInput, "password123");
const submitButton = canvas.getByRole("button", { name: /Sign Up/i });
await expect(submitButton).toBeEnabled();
await userEvent.click(submitButton);
},
};
export const FillAndFixForm: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const nameInput = canvas.getByLabelText(/Full Name/i);
const ageInput = canvas.getByLabelText(/Age/i);
const passwordInput = canvas.getByLabelText(/Password/i);
const submitButton = canvas.getByRole("button", { name: /Sign Up/i });
await userEvent.type(nameInput, "1234");
await userEvent.type(ageInput, "10");
await userEvent.type(passwordInput, "12");
await userEvent.tab();
await expect(
canvas.queryByText(/Please enter letters only/i),
).toBeInTheDocument();
await expect(
canvas.queryByText(/Age must be at least 18/),
).toBeInTheDocument();
await expect(
canvas.queryByText(/Password must be at least 8 characters long/),
).toBeInTheDocument();
await expect(submitButton).toBeDisabled();
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "Somayeh Ghanavati");
await userEvent.clear(ageInput);
await userEvent.type(ageInput, "36");
await userEvent.clear(passwordInput);
await userEvent.type(passwordInput, "123456789");
await expect(
canvas.queryByText(/Please enter letters only/i),
).not.toBeInTheDocument();
await expect(
canvas.queryByText(/Age must be at least 18/),
).not.toBeInTheDocument();
await expect(
canvas.queryByText(/Password must be at least 8 characters long/),
).not.toBeInTheDocument();
await expect(submitButton).toBeEnabled();
await userEvent.click(submitButton);
},
};
+33 -3
View File
@@ -1,7 +1,37 @@
import react from '@vitejs/plugin-react' /// <reference types="vitest/config" />
import { defineConfig } from 'vite' import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
// https://vite.dev/config/ // https://vite.dev/config/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
import { playwright } from '@vitest/browser-playwright';
const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
}) test: {
projects: [{
extends: true,
plugins: [
// The plugin will run tests for the stories defined in your Storybook config
// See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
storybookTest({
configDir: path.join(dirname, '.storybook')
})],
test: {
name: 'storybook',
browser: {
enabled: true,
headless: true,
provider: playwright({}),
instances: [{
browser: 'chromium'
}]
}
}
}]
}
});
+1
View File
@@ -0,0 +1 @@
/// <reference types="@vitest/browser-playwright" />