Compare commits

..

No commits in common. "develop" and "v3.1.1" have entirely different histories.

65 changed files with 22759 additions and 10436 deletions

View File

@ -1,4 +1,4 @@
# https://editorconfig.org/
# For more information see: https://editorconfig.org/
root = true

5
.eslintignore Normal file
View File

@ -0,0 +1,5 @@
node_modules
build
dist
.parcel-cache
example

View File

@ -1,8 +1,6 @@
{
"extends": ["conventions", "prettier"],
"ignorePatterns": ["tsup.config.js", "example"],
"plugins": ["prettier", "import", "unicorn"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.json"
},

View File

@ -1,8 +1,8 @@
---
name: "🐛 Bug Report"
about: "Report an unexpected problem or unintended behavior."
title: "[Bug]"
labels: "bug"
name: '🐛 Bug Report'
about: 'Report an unexpected problem or unintended behavior.'
title: '[Bug]'
labels: 'bug'
---
<!--

View File

@ -1,8 +1,8 @@
---
name: "📜 Documentation"
about: "Correct spelling errors, improvements or additions to documentation files (README, CONTRIBUTING...)."
title: "[Documentation]"
labels: "documentation"
name: '📜 Documentation'
about: 'Correct spelling errors, improvements or additions to documentation files (README, CONTRIBUTING...).'
title: '[Documentation]'
labels: 'documentation'
---
<!-- Please make sure your issue has not already been fixed. -->

View File

@ -1,8 +1,8 @@
---
name: "✨ Feature Request"
about: "Suggest a new feature idea."
title: "[Feature]"
labels: "feature request"
name: '✨ Feature Request'
about: 'Suggest a new feature idea.'
title: '[Feature]'
labels: 'feature request'
---
<!-- Please make sure your issue has not already been fixed. -->

View File

@ -1,8 +1,8 @@
---
name: "🔧 Improvement"
about: "Improve structure/format/performance/refactor/tests of the code."
title: "[Improvement]"
labels: "improvement"
name: '🔧 Improvement'
about: 'Improve structure/format/performance/refactor/tests of the code.'
title: '[Improvement]'
labels: 'improvement'
---
<!-- Please make sure your issue has not already been fixed. -->

View File

@ -1,8 +1,8 @@
---
name: "🙋 Question"
about: "Further information is requested."
title: "[Question]"
labels: "question"
name: '🙋 Question'
about: 'Further information is requested.'
title: '[Question]'
labels: 'question'
---
### Question

View File

@ -1,6 +1,6 @@
<!-- Please first discuss the change you wish to make via issue before making a change. It might avoid a waste of your time. -->
# What changes this PR introduce?
## What changes this PR introduce?
## List any relevant issue numbers

View File

@ -1,4 +1,4 @@
name: "Build"
name: 'Build'
on:
push:
@ -8,21 +8,21 @@ on:
jobs:
build:
runs-on: "ubuntu-latest"
runs-on: 'ubuntu-latest'
steps:
- uses: "actions/checkout@v4.1.1"
- uses: 'actions/checkout@v3.0.0'
- name: "Setup Node.js"
uses: "actions/setup-node@v4.0.1"
- name: 'Use Node.js'
uses: 'actions/setup-node@v3.1.0'
with:
node-version: "lts/*"
cache: "npm"
node-version: 'lts/*'
cache: 'npm'
- name: "Install dependencies"
run: "npm clean-install"
- name: 'Install'
run: 'npm install'
- name: "Build Package"
run: "npm run build"
- name: 'Build Package'
run: 'npm run build'
- name: "Build Example"
run: "cd example && npm clean-install && npm run build"
- name: 'Build Example'
run: 'cd example && npm install && npm run build'

View File

@ -1,4 +1,4 @@
name: "Lint"
name: 'Lint'
on:
push:
@ -8,21 +8,21 @@ on:
jobs:
lint:
runs-on: "ubuntu-latest"
runs-on: 'ubuntu-latest'
steps:
- uses: "actions/checkout@v4.1.1"
- uses: 'actions/checkout@v3.0.0'
- name: "Setup Node.js"
uses: "actions/setup-node@v4.0.1"
- name: 'Use Node.js'
uses: 'actions/setup-node@v3.1.0'
with:
node-version: "lts/*"
cache: "npm"
node-version: 'lts/*'
cache: 'npm'
- name: "Install dependencies"
run: "npm clean-install"
- name: 'Install'
run: 'npm install'
- run: 'npm run lint:commit -- --to "${{ github.sha }}"'
- run: "npm run lint:editorconfig"
- run: "npm run lint:markdown"
- run: "npm run lint:eslint"
- run: "npm run lint:prettier"
- run: 'npm run lint:editorconfig'
- run: 'npm run lint:markdown'
- run: 'npm run lint:typescript'
- run: 'npm run lint:prettier'

View File

@ -1,4 +1,4 @@
name: "Release"
name: 'Release'
on:
push:
@ -6,32 +6,24 @@ on:
jobs:
build:
runs-on: "ubuntu-latest"
permissions:
contents: "write"
issues: "write"
pull-requests: "write"
id-token: "write"
runs-on: 'ubuntu-latest'
steps:
- uses: "actions/checkout@v4.1.1"
- uses: 'actions/checkout@v3.0.0'
- name: "Setup Node.js"
uses: "actions/setup-node@v4.0.1"
- name: 'Use Node.js'
uses: 'actions/setup-node@v3.1.0'
with:
node-version: "lts/*"
cache: "npm"
node-version: 'lts/*'
cache: 'npm'
- name: "Install dependencies"
run: "npm clean-install"
- name: 'Install'
run: 'npm install'
- name: "Build Package"
run: "npm run build"
- name: 'Build Package'
run: 'npm run build'
- name: "Verify the integrity of provenance attestations and registry signatures for installed dependencies"
run: "npm audit signatures"
- name: "Release"
run: "npm run release"
- name: 'Release'
run: 'npm run release'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@ -1,4 +1,4 @@
name: "Test"
name: 'Test'
on:
push:
@ -8,41 +8,41 @@ on:
jobs:
test:
runs-on: "ubuntu-latest"
runs-on: 'ubuntu-latest'
steps:
- uses: "actions/checkout@v4.1.1"
- uses: 'actions/checkout@v3.0.0'
- name: "Setup Node.js"
uses: "actions/setup-node@v4.0.1"
- name: 'Use Node.js'
uses: 'actions/setup-node@v3.1.0'
with:
node-version: "lts/*"
cache: "npm"
node-version: 'lts/*'
cache: 'npm'
- name: "Install dependencies"
run: "npm clean-install"
- name: 'Install'
run: 'npm install'
- name: "Test"
run: "npm run test"
- name: 'Test'
run: 'npm run test'
test-e2e:
runs-on: "ubuntu-latest"
runs-on: 'ubuntu-latest'
steps:
- uses: "actions/checkout@v4.1.1"
- uses: 'actions/checkout@v3.0.0'
- name: "Setup Node.js"
uses: "actions/setup-node@v4.0.1"
- name: 'Use Node.js'
uses: 'actions/setup-node@v3.1.0'
with:
node-version: "lts/*"
cache: "npm"
node-version: 'lts/*'
cache: 'npm'
- name: "Install dependencies"
run: "npm clean-install"
- name: 'Install'
run: 'npm install'
- name: "Build Package"
run: "npm run build"
- name: 'Build Package'
run: 'npm run build'
- name: "Build Example"
run: "cd example && npm clean-install && npm run build"
- name: 'Build Example'
run: 'cd example && npm install && npm run build'
- name: "End To End (e2e) Test Example"
run: "cd example && npm run test:e2e"
- name: 'End To End (e2e) Test Example'
run: 'cd example && npm run test:e2e'

1
.gitignore vendored
View File

@ -5,7 +5,6 @@ node_modules
# production
build
dist
.next
# testing
coverage

View File

@ -1,11 +1,11 @@
{
"config": {
"extends": "markdownlint/style/prettier",
"default": true,
"relative-links": true,
"no-inline-html": false,
"MD013": false,
"MD024": false,
"MD033": false,
"MD041": false
},
"globs": ["**/*.md"],
"ignores": ["**/node_modules"],
"customRules": ["markdownlint-rule-relative-links"],
"globs": ["**/*.{md,mdx}"],
"ignores": ["**/node_modules"]
}

1
.npmrc
View File

@ -1,2 +1 @@
save-exact=true
provenance=true

View File

@ -1,3 +1,6 @@
{
"semi": false
"singleQuote": true,
"jsxSingleQuote": true,
"semi": false,
"trailingComma": "none"
}

View File

@ -60,7 +60,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
<contact@theoludwig.fr>.
contact@divlo.fr.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the

View File

@ -6,10 +6,6 @@ Thanks a lot for your interest in contributing to **react-component-form**! 🎉
**react-component-form** has adopted the [Contributor Covenant](https://www.contributor-covenant.org/) as its Code of Conduct, and we expect project participants to adhere to it. Please read [the full text](./CODE_OF_CONDUCT.md) so that you can understand what actions will and will not be tolerated.
## Open Development
All work on **react-component-form** happens directly on this repository. Both core team members and external contributors send pull requests which go through the same review process.
## Types of contributions
- Reporting a bug.
@ -29,4 +25,28 @@ If you're adding new features to **react-component-form**, please include tests.
## Commits
The commit message guidelines adheres to [Conventional Commits](https://www.conventionalcommits.org/) and [Semantic Versioning](https://semver.org/) for releases.
The commit message guidelines respect
[@commitlint/config-conventional](https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional)
and [Semantic Versioning](https://semver.org/) for releases.
### Types
Types define which kind of changes you made to the project.
| Types | Description |
| -------- | ------------------------------------------------------------------------------------------------------------ |
| feat | A new feature. |
| fix | A bug fix. |
| docs | Documentation only changes. |
| style | Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc). |
| refactor | A code change that neither fixes a bug nor adds a feature. |
| perf | A code change that improves performance. |
| test | Adding missing tests or correcting existing tests. |
| build | Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm). |
| ci | Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs). |
| chore | Other changes that don't modify src or test files. |
| revert | Reverts a previous commit. |
### Scopes
Scopes define what part of the code changed.

View File

@ -1,6 +1,6 @@
# MIT License
MIT License
Copyright (c) Théo LUDWIG <contact@theoludwig.fr>
Copyright (c) Divlo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@ -5,17 +5,13 @@
</p>
<p align="center">
<strong>⚠️ This project is not maintained anymore, you can still use the code as you wish and fork it to maintain it yourself.</strong>
</p>
<p align="center">
<a href="./CONTRIBUTING.md"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" alt="CONTRIBUTING /></a>
<a href="./CONTRIBUTING.md"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/licence-MIT-blue.svg" alt="Licence MIT"/></a>
<a href="./CODE_OF_CONDUCT.md"><img src="https://img.shields.io/badge/Contributor%20Covenant-v2.0%20adopted-ff69b4.svg" alt="Contributor Covenant" /></a>
<br />
<a href="https://github.com/theoludwig/react-component-form/actions/workflows/build.yml"><img src="https://github.com/theoludwig/react-component-form/actions/workflows/build.yml/badge.svg?branch=develop" alt="Build" /></a>
<a href="https://github.com/theoludwig/react-component-form/actions/workflows/lint.yml"><img src="https://github.com/theoludwig/react-component-form/actions/workflows/lint.yml/badge.svg?branch=develop" alt="Lint" /></a>
<a href="https://github.com/theoludwig/react-component-form/actions/workflows/test.yml"><img src="https://github.com/theoludwig/react-component-form/actions/workflows/test.yml/badge.svg?branch=develop" alt="Test" /></a>
<a href="https://github.com/Divlo/react-component-form/actions/workflows/build.yml"><img src="https://github.com/Divlo/react-component-form/actions/workflows/build.yml/badge.svg?branch=master" /></a>
<a href="https://github.com/Divlo/react-component-form/actions/workflows/lint.yml"><img src="https://github.com/Divlo/react-component-form/actions/workflows/lint.yml/badge.svg?branch=master" /></a>
<a href="https://github.com/Divlo/react-component-form/actions/workflows/test.yml"><img src="https://github.com/Divlo/react-component-form/actions/workflows/test.yml/badge.svg?branch=master" /></a>
<br />
<a href="https://conventionalcommits.org"><img src="https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg" alt="Conventional Commits" /></a>
<a href="https://github.com/semantic-release/semantic-release"><img src="https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg" alt="semantic-release" /></a>
@ -38,12 +34,12 @@ npm install --save react-component-form
## ⚙️ Usage
_Note: The examples use TypeScript, but obviously you can use JavaScript. Be aware that `HandleForm` is the type definition for the `onChange` and `onSubmit` props._
_Note : The examples use TypeScript, but obviously you can use JavaScript. Be aware that `HandleForm` is the type definition for the `onChange` and `onSubmit` props._
```tsx
import React from "react"
import { Form } from "react-component-form"
import type { HandleForm } from "react-component-form"
import React from 'react'
import { Form } from 'react-component-form'
import type { HandleForm } from 'react-component-form'
export const Example = () => {
const handleSubmit: HandleForm = (formData, formElement) => {
@ -53,8 +49,8 @@ export const Example = () => {
return (
<Form onSubmit={handleSubmit}>
<input type="text" name="inputName" />
<button type="submit">Submit</button>
<input type='text' name='inputName' />
<button type='submit'>Submit</button>
</Form>
)
}
@ -74,16 +70,16 @@ This example shows how to use the `<Form />` component with `useForm` hook to va
You can see a more detailled example in the [./example](./example) folder.
```tsx
import React from "react"
import { Form, useForm } from "react-component-form"
import type { HandleUseFormCallback } from "react-component-form"
import React from 'react'
import { Form, useForm } from 'react-component-form'
import type { HandleUseFormCallback } from 'react-component-form'
const schema = {
inputName: {
type: "string",
type: 'string',
minLength: 3,
maxLength: 20,
},
maxLength: 20
}
}
export const Example = () => {
@ -91,24 +87,24 @@ export const Example = () => {
const onSubmit: HandleUseFormCallback<typeof schema> = (
formData,
formElement,
formElement
) => {
console.log(formData) // { inputName: 'value of the input validated and type-safe' }
console.log(formData) // { inputName: 'value of the input validated' }
formElement.reset()
// The return can be either `null` or an object with a global message of type `'error' | 'success'`.
return {
type: "success",
message: "Success: Form submitted",
type: 'success',
value: 'Success: Form submitted'
}
}
return (
<Form onSubmit={handleUseForm(onSubmit)}>
<input type="text" name="inputName" />
<input type='text' name='inputName' />
{errors.inputName != null && <p>{errors.inputName[0].message}</p>}
<button type="submit">Submit</button>
<button type='submit'>Submit</button>
{message != null && <p>{message}</p>}
</Form>

View File

@ -1,27 +1,27 @@
import Translation from "next-translate/Trans"
import Translation from 'next-translate/Trans'
import { Link } from "./design/Link"
import { TextSpecial } from "./design/TextSpecial"
import { Link } from './design/Link'
import { TextSpecial } from './design/TextSpecial'
export const About: React.FC = () => {
return (
<section className="text-center mt-6">
<h1 className="text-4xl">{"<Form />"}</h1>
<h2 className="text-xl dark:text-gray-300 text-gray-600 mt-4">
npm install --save{" "}
<section className='text-center mt-6'>
<h1 className='text-4xl'>{'<Form />'}</h1>
<h2 className='text-xl dark:text-gray-300 text-gray-600 mt-4'>
npm install --save{' '}
<Link
href="https://www.npmjs.com/package/react-component-form"
target="_blank"
rel="noopener noreferrer"
href='https://www.npmjs.com/package/react-component-form'
target='_blank'
rel='noopener noreferrer'
>
react-component-form
</Link>
</h2>
<p className="max-w-lg mt-6 text-base" data-cy="main-description">
<p className='max-w-lg mt-6 text-base' data-cy='main-description'>
<Translation
i18nKey="common:about"
components={[<TextSpecial key="special" />]}
i18nKey='common:about'
components={[<TextSpecial key='special' />]}
/>
</p>
</section>

View File

@ -1,16 +1,14 @@
"use client"
import { Form, useForm } from 'react-component-form'
import type { HandleUseFormCallback } from 'react-component-form'
import useTranslation from 'next-translate/useTranslation'
import { Form, useForm } from "react-component-form"
import type { HandleUseFormCallback } from "react-component-form"
import useTranslation from "next-translate/useTranslation"
import { Input } from './design/Input'
import { Button } from './design/Button'
import { useFormTranslation } from '../hooks/useFormTranslation'
import { userSchema } from '../models/User'
import { FormState } from './design/FormState'
import { Input } from "./design/Input"
import { Button } from "./design/Button"
import { useFormTranslation } from "../hooks/useFormTranslation"
import { userSchema } from "../models/User"
import { FormState } from "./design/FormState"
const fakeServerRequest = async (ms: number): Promise<void> => {
const simulateServerRequest = async (ms: number): Promise<void> => {
return await new Promise((resolve) => {
setTimeout(resolve, ms)
})
@ -23,47 +21,47 @@ export const FormExample: React.FC = () => {
const onSubmit: HandleUseFormCallback<typeof userSchema> = async (
formData,
formElement,
formElement
) => {
await fakeServerRequest(2_000)
console.log("onSubmit:", formData)
await simulateServerRequest(2000)
console.log('onSubmit:', formData)
formElement.reset()
return {
type: "success",
message: "common:success-message",
type: 'success',
value: 'common:success-message'
}
}
return (
<section>
<Form
className="mt-6 w-[90%] max-w-xs"
className='mt-6 w-[90%] max-w-xs'
noValidate
onSubmit={handleUseForm(onSubmit)}
>
<Input
type="text"
placeholder={t("common:name")}
name="name"
label={t("common:name")}
type='text'
placeholder={t('common:name')}
name='name'
label={t('common:name')}
error={getFirstErrorTranslation(errors.name)}
/>
<Input
type="text"
placeholder="Email"
name="email"
label="Email"
type='text'
placeholder='Email'
name='email'
label='Email'
error={getFirstErrorTranslation(errors.email)}
/>
<Button className="mt-6 w-full" type="submit" data-cy="submit">
<Button className='mt-6 w-full' type='submit' data-cy='submit'>
Submit
</Button>
</Form>
<FormState
id="message"
id='message'
state={fetchState}
message={message != null ? t(message) : undefined}
/>

View File

@ -1,9 +1,9 @@
import { Language } from "./Language"
import { SwitchTheme } from "./SwitchTheme"
import { Language } from './Language'
import { SwitchTheme } from './SwitchTheme'
export const Header: React.FC = () => {
return (
<header className="flex justify-center mt-6">
<header className='flex justify-center mt-6'>
<Language />
<SwitchTheme />
</header>

View File

@ -1,15 +1,15 @@
export const Arrow: React.FC = () => {
return (
<svg
width="12"
height="8"
viewBox="0 0 12 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
width='12'
height='8'
viewBox='0 0 12 8'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
className="fill-current text-black dark:text-white"
d="M9.8024 0.292969L5.61855 4.58597L1.43469 0.292969L0.0566406 1.70697L5.61855 7.41397L11.1805 1.70697L9.8024 0.292969Z"
className='fill-current text-black dark:text-white'
d='M9.8024 0.292969L5.61855 4.58597L1.43469 0.292969L0.0566406 1.70697L5.61855 7.41397L11.1805 1.70697L9.8024 0.292969Z'
/>
</svg>
)

View File

@ -1,4 +1,4 @@
import Image from "next/image"
import Image from 'next/image'
export interface LanguageFlagProps {
language: string
@ -16,7 +16,7 @@ export const LanguageFlag: React.FC<LanguageFlagProps> = (props) => {
src={`/images/languages/${language}.svg`}
alt={language}
/>
<p data-cy="language-flag-text" className="mx-2 text-base">
<p data-cy='language-flag-text' className='mx-2 text-base'>
{language.toUpperCase()}
</p>
</>

View File

@ -1,11 +1,11 @@
import { useCallback, useEffect, useState, useRef } from "react"
import useTranslation from "next-translate/useTranslation"
import setLanguage from "next-translate/setLanguage"
import classNames from "clsx"
import { useCallback, useEffect, useState, useRef } from 'react'
import useTranslation from 'next-translate/useTranslation'
import setLanguage from 'next-translate/setLanguage'
import classNames from 'clsx'
import i18n from "../../../i18n.json"
import { Arrow } from "./Arrow"
import { LanguageFlag } from "./LanguageFlag"
import i18n from '../../../i18n.json'
import { Arrow } from './Arrow'
import { LanguageFlag } from './LanguageFlag'
export const Language: React.FC = () => {
const { lang: currentLanguage } = useTranslation()
@ -28,10 +28,10 @@ export const Language: React.FC = () => {
}
}
window.document.addEventListener("click", handleClickEvent)
window.document.addEventListener('click', handleClickEvent)
return () => {
return window.removeEventListener("click", handleClickEvent)
return window.removeEventListener('click', handleClickEvent)
}
}, [])
@ -40,11 +40,11 @@ export const Language: React.FC = () => {
}
return (
<div className="flex cursor-pointer flex-col items-center justify-center">
<div className='flex cursor-pointer flex-col items-center justify-center'>
<div
ref={languageClickRef}
data-cy="language-click"
className="mr-5 flex items-center"
data-cy='language-click'
className='mr-5 flex items-center'
onClick={handleHiddenMenu}
>
<LanguageFlag language={currentLanguage} />
@ -52,10 +52,10 @@ export const Language: React.FC = () => {
</div>
<ul
data-cy="languages-list"
data-cy='languages-list'
className={classNames(
"absolute top-14 z-10 mt-3 mr-4 flex w-24 list-none flex-col items-center justify-center rounded-lg bg-white p-0 shadow-lightFlag dark:bg-black dark:shadow-darkFlag",
{ hidden: hiddenMenu },
'absolute top-14 z-10 mt-3 mr-4 flex w-24 list-none flex-col items-center justify-center rounded-lg bg-white p-0 shadow-lightFlag dark:bg-black dark:shadow-darkFlag',
{ hidden: hiddenMenu }
)}
>
{i18n.locales.map((language, index) => {
@ -65,7 +65,7 @@ export const Language: React.FC = () => {
return (
<li
key={index}
className="flex h-12 w-full items-center justify-center pl-2 hover:bg-[#4f545c] hover:bg-opacity-20"
className='flex h-12 w-full items-center justify-center pl-2 hover:bg-[#4f545c] hover:bg-opacity-20'
onClick={async () => {
await handleLanguage(language)
}}

View File

@ -1,6 +1,5 @@
import { useEffect, useState } from "react"
import classNames from "clsx"
import { useTheme } from "next-themes"
import { useEffect, useState } from 'react'
import { useTheme } from 'next-themes'
export const SwitchTheme: React.FC = () => {
const [mounted, setMounted] = useState(false)
@ -15,64 +14,113 @@ export const SwitchTheme: React.FC = () => {
}
const handleClick = (): void => {
setTheme(theme === "dark" ? "light" : "dark")
setTheme(theme === 'dark' ? 'light' : 'dark')
}
return (
<div
className="flex items-center"
data-cy="switch-theme-click"
onClick={handleClick}
>
<div className="relative inline-block cursor-pointer touch-pan-x select-none border-0 bg-transparent p-0">
<div className="h-[24px] w-[50px] rounded-[30px] bg-[#4d4d4d] p-0 text-white transition-all duration-200 ease-in-out">
<div
data-cy="switch-theme-dark"
className={classNames(
"absolute top-0 bottom-0 left-[8px] mt-auto mb-auto h-[10px] w-[14px] leading-[0] transition-opacity duration-[250ms] ease-in-out",
{
"opacity-100": theme === "dark",
"opacity-0": theme === "light",
},
)}
>
<span className="relative flex h-[10px] w-[10px] items-center justify-center">
🌜
</span>
</div>
<div
data-cy="switch-theme-light"
className={classNames(
"absolute right-[10px] top-0 bottom-0 mt-auto mb-auto h-[10px] w-[10px] leading-[0]",
{
"opacity-100": theme === "light",
"opacity-0": theme === "dark",
},
)}
>
<span className="relative flex h-[10px] w-[10px] items-center justify-center">
🌞
</span>
<>
<div
className='flex items-center'
data-cy='switch-theme-click'
onClick={handleClick}
>
<div className='toggle-theme-button relative inline-block cursor-pointer bg-transparent'>
<div className='toggle-track'>
<div
data-cy='switch-theme-dark'
className='toggle-track-check absolute'
>
<span className='toggle_Dark relative flex items-center justify-center'>
🌜
</span>
</div>
<div
data-cy='switch-theme-light'
className='toggle-track-x absolute'
>
<span className='toggle_Light relative flex items-center justify-center'>
🌞
</span>
</div>
</div>
<div className='toggle-thumb absolute' />
<input
data-cy='switch-theme-input'
type='checkbox'
aria-label='Dark mode toggle'
className='toggle-screenreader-only absolute overflow-hidden'
defaultChecked
/>
</div>
<div
className={classNames(
"absolute top-[1px] box-border h-[22px] w-[22px] rounded-[50%] bg-[#fafafa] text-white transition-all duration-[250ms] ease-in-out",
{
"left-[27px]": theme === "dark",
"left-0": theme === "light",
},
)}
style={{ border: "1px solid #4d4d4d" }}
/>
<input
data-cy="switch-theme-input"
type="checkbox"
aria-label="Dark mode toggle"
className="absolute m-[-1px] h-[1px] w-[1px] overflow-hidden border-0 p-0"
defaultChecked
/>
</div>
</div>
<style jsx>
{`
.toggle-theme-button {
touch-action: pan-x;
border: 0;
padding: 0;
user-select: none;
}
.toggle-track {
width: 50px;
height: 24px;
padding: 0;
border-radius: 30px;
background-color: #4d4d4d;
transition: all 0.2s ease;
color: #fff;
}
.toggle-track-check {
width: 14px;
height: 10px;
top: 0;
bottom: 0;
margin-top: auto;
margin-bottom: auto;
line-height: 0;
left: 8px;
opacity: ${theme === 'dark' ? 1 : 0};
transition: opacity 0.25s ease;
}
.toggle-track-x {
width: 10px;
height: 10px;
top: 0;
bottom: 0;
margin-top: auto;
margin-bottom: auto;
line-height: 0;
right: 10px;
opacity: ${theme === 'dark' ? 0 : 1};
}
.toggle_Dark,
.toggle_Light {
height: 10px;
width: 10px;
}
.toggle-thumb {
left: ${theme === 'dark' ? '27px' : '0px'};
width: 22px;
height: 22px;
border: 1px solid #4d4d4d;
border-radius: 50%;
background-color: #fafafa;
box-sizing: border-box;
transition: all 0.25s ease;
top: 1px;
color: #fff;
}
.toggle-screenreader-only {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
padding: 0;
width: 1px;
}
`}
</style>
</>
)
}

View File

@ -1 +1 @@
export * from "./Header"
export * from './Header'

View File

@ -1,6 +1,6 @@
import classNames from "clsx"
import classNames from 'clsx'
export interface ButtonProps extends React.ComponentPropsWithoutRef<"button"> {}
export interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> {}
export const Button: React.FC<ButtonProps> = (props) => {
const { children, className, ...rest } = props
@ -8,8 +8,8 @@ export const Button: React.FC<ButtonProps> = (props) => {
return (
<button
className={classNames(
"py-2 px-6 font-paragraph rounded-lg bg-transparent border hover:text-white dark:hover:text-black fill-current stroke-current transform transition-colors duration-300 ease-in-out focus:outline-none focus:text-white dark:focus:text-black border-green-800 dark:border-green-400 text-green-800 dark:text-green-400 hover:bg-green-800 focus:bg-green-800 dark:focus:bg-green-400 dark:hover:bg-green-400",
className,
'py-2 px-6 font-paragraph rounded-lg bg-transparent border hover:text-white dark:hover:text-black fill-current stroke-current transform transition-colors duration-300 ease-in-out focus:outline-none focus:text-white dark:focus:text-black border-green-800 dark:border-green-400 text-green-800 dark:text-green-400 hover:bg-green-800 focus:bg-green-800 dark:focus:bg-green-400 dark:hover:bg-green-400',
className
)}
{...rest}
>

View File

@ -1,10 +1,10 @@
import classNames from "clsx"
import useTranslation from "next-translate/useTranslation"
import type { FetchState as FormStateType } from "react-component-form"
import classNames from 'clsx'
import useTranslation from 'next-translate/useTranslation'
import type { FetchState as FormStateType } from 'react-component-form'
import { Loader } from "./Loader"
import { Loader } from './Loader'
export interface FormStateProps extends React.ComponentPropsWithoutRef<"div"> {
export interface FormStateProps extends React.ComponentPropsWithoutRef<'div'> {
state: FormStateType
message?: string
id?: string
@ -14,15 +14,15 @@ export const FormState: React.FC<FormStateProps> = (props) => {
const { state, message, id, ...rest } = props
const { t } = useTranslation()
if (state === "loading") {
if (state === 'loading') {
return (
<div data-cy="loader" className="mt-8 flex justify-center">
<div data-cy='loader' className='mt-8 flex justify-center'>
<Loader />
</div>
)
}
if (state === "idle" || message == null) {
if (state === 'idle' || message == null) {
return null
}
@ -32,15 +32,15 @@ export const FormState: React.FC<FormStateProps> = (props) => {
{...rest}
className={classNames(
props.className,
"mt-6 flex max-w-xl items-center text-center font-medium",
'mt-6 flex max-w-xl items-center text-center font-medium',
{
"text-red-800 dark:text-red-400": state === "error",
"text-green-800 dark:text-green-400": state === "success",
},
'text-red-800 dark:text-red-400': state === 'error',
'text-green-800 dark:text-green-400': state === 'success'
}
)}
>
<div className="inline bg-cover font-headline" />
<span id={id} className="pl-2">
<div className='inline bg-cover font-headline' />
<span id={id} className='pl-2'>
<b>{t(`common:${state}`)}:</b> {message}
</span>
</div>

View File

@ -1,8 +1,8 @@
import classNames from "clsx"
import classNames from 'clsx'
import { FormState } from "./FormState"
import { FormState } from './FormState'
export interface InputProps extends React.ComponentPropsWithRef<"input"> {
export interface InputProps extends React.ComponentPropsWithRef<'input'> {
label: string
error?: string
className?: string
@ -12,23 +12,23 @@ export const Input: React.FC<InputProps> = (props) => {
const { label, name, className, error, ...rest } = props
return (
<div className="flex flex-col">
<div className={classNames("mt-6 mb-2 flex justify-between", className)}>
<label className="pl-1" htmlFor={name}>
<div className='flex flex-col'>
<div className={classNames('mt-6 mb-2 flex justify-between', className)}>
<label className='pl-1' htmlFor={name}>
{label}
</label>
</div>
<div className="relative mt-0">
<div className='relative mt-0'>
<input
className="h-11 w-full rounded-lg border border-transparent bg-[#f1f1f1] px-3 font-paragraph leading-10 text-[#2a2a2a] caret-green-600 focus:border focus:shadow-green focus:outline-none"
className='h-11 w-full rounded-lg border border-transparent bg-[#f1f1f1] px-3 font-paragraph leading-10 text-[#2a2a2a] caret-green-600 focus:border focus:shadow-green focus:outline-none'
{...rest}
id={name}
name={name}
data-cy={`input-${name ?? "name"}`}
data-cy={`input-${name ?? 'name'}`}
/>
<FormState
id={`error-${name ?? "input"}`}
state={error == null ? "idle" : "error"}
id={`error-${name ?? 'input'}`}
state={error == null ? 'idle' : 'error'}
message={error}
/>
</div>

View File

@ -1,6 +1,6 @@
import classNames from "clsx"
import classNames from 'clsx'
export interface LinkProps extends React.ComponentPropsWithoutRef<"a"> {}
export interface LinkProps extends React.ComponentPropsWithoutRef<'a'> {}
export const Link: React.FC<LinkProps> = (props) => {
const { children, className, ...rest } = props
@ -8,8 +8,8 @@ export const Link: React.FC<LinkProps> = (props) => {
return (
<a
className={classNames(
"text-green-800 hover:underline dark:text-green-400",
className,
'text-green-800 hover:underline dark:text-green-400',
className
)}
{...rest}
>

View File

@ -0,0 +1,81 @@
export interface LoaderProps {
width?: number
height?: number
className?: string
}
export const Loader: React.FC<LoaderProps> = (props) => {
const { width = 50, height = 50 } = props
return (
<div className={props.className}>
<div data-cy='progress-spinner' className='progress-spinner'>
<svg className='progress-spinner-svg' viewBox='25 25 50 50'>
<circle
className='progress-spinner-circle'
cx='50'
cy='50'
r='20'
fill='none'
strokeWidth='2'
strokeMiterlimit='10'
/>
</svg>
</div>
<style jsx>
{`
.progress-spinner {
position: relative;
margin: 0 auto;
width: ${width}px;
height: ${height}px;
}
.progress-spinner::before {
content: '';
display: block;
padding-top: 100%;
}
.progress-spinner-svg {
animation: progress-spinner-rotate 2s linear infinite;
height: 100%;
transform-origin: center center;
width: 100%;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
}
.progress-spinner-circle {
stroke-dasharray: 89, 200;
stroke-dashoffset: 0;
stroke: #27b05e;
animation: progress-spinner-dash 1.5s ease-in-out infinite;
stroke-linecap: round;
}
@keyframes progress-spinner-rotate {
100% {
transform: rotate(360deg);
}
}
@keyframes progress-spinner-dash {
0% {
stroke-dasharray: 1, 200;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 89, 200;
stroke-dashoffset: -35px;
}
100% {
stroke-dasharray: 89, 200;
stroke-dashoffset: -124px;
}
}
`}
</style>
</div>
)
}

View File

@ -1,39 +0,0 @@
@keyframes progressSpinnerRotate {
100% {
transform: rotate(360deg);
}
}
@keyframes progressSpinnerDash {
0% {
stroke-dasharray: 1, 200;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 89, 200;
stroke-dashoffset: -35px;
}
100% {
stroke-dasharray: 89, 200;
stroke-dashoffset: -124px;
}
}
.progressSpinnerSvg {
animation: progressSpinnerRotate 2s linear infinite;
height: 100%;
transform-origin: center center;
width: 100%;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
}
.progressSpinnerCircle {
stroke-dasharray: 89, 200;
stroke-dashoffset: 0;
stroke: #27b05e;
animation: progressSpinnerDash 1.5s ease-in-out infinite;
stroke-linecap: round;
}

View File

@ -1,33 +0,0 @@
import styles from "./Loader.module.css"
export interface LoaderProps {
width?: number
height?: number
className?: string
}
export const Loader: React.FC<LoaderProps> = (props) => {
const { width = 50, height = 50, className } = props
return (
<div className={className}>
<div
data-cy="progress-spinner"
className="relative my-0 mx-auto before:content-none before:block before:pt-[100%]"
style={{ width: `${width}px`, height: `${height}px` }}
>
<svg className={styles["progressSpinnerSvg"]} viewBox="25 25 50 50">
<circle
className={styles["progressSpinnerCircle"]}
cx="50"
cy="50"
r="20"
fill="none"
strokeWidth="2"
strokeMiterlimit="10"
/>
</svg>
</div>
</div>
)
}

View File

@ -1 +0,0 @@
export * from "./Loader"

View File

@ -1,14 +1,14 @@
import classNames from "clsx"
import classNames from 'clsx'
export interface TextSpecialProps
extends React.ComponentPropsWithoutRef<"span"> {}
extends React.ComponentPropsWithoutRef<'span'> {}
export const TextSpecial: React.FC<TextSpecialProps> = (props) => {
const { children, className, ...rest } = props
return (
<span
className={classNames("text-green-800 dark:text-green-400", className)}
className={classNames('text-green-800 dark:text-green-400', className)}
{...rest}
>
{children}

View File

@ -1,12 +1,13 @@
import { defineConfig } from "cypress"
import { defineConfig } from 'cypress'
export default defineConfig({
fixturesFolder: false,
video: false,
downloadsFolder: undefined,
screenshotOnRunFailure: false,
e2e: {
baseUrl: "http://127.0.0.1:3000",
supportFile: false,
},
baseUrl: 'http://localhost:3000',
supportFile: false
}
})

View File

@ -1,63 +1,63 @@
describe("Form", () => {
describe('Form', () => {
beforeEach(() => {
cy.visit("/")
cy.visit('/')
})
it("succeeds, reset input values and display the global success message", () => {
cy.get("[data-cy=input-name]").type("John")
cy.get("[data-cy=input-email]").type("john@john.com")
cy.get("#error-name").should("not.exist")
cy.get("#error-email").should("not.exist")
cy.get("[data-cy=submit]").click()
cy.get("[data-cy=input-name]").should("have.value", "")
cy.get("[data-cy=input-email]").should("have.value", "")
cy.get("#message").should(
"have.text",
"Success: The form has been submitted.",
it('suceeds, reset input values and display the global success message', () => {
cy.get('[data-cy=input-name]').type('John')
cy.get('[data-cy=input-email]').type('john@john.com')
cy.get('#error-name').should('not.exist')
cy.get('#error-email').should('not.exist')
cy.get('[data-cy=submit]').click()
cy.get('[data-cy=input-name]').should('have.value', '')
cy.get('[data-cy=input-email]').should('have.value', '')
cy.get('#message').should(
'have.text',
'Success: The form has been submitted.'
)
})
it("fails with all inputs as required with error messages and update error messages when updating language (translation)", () => {
it('fails with all inputs as required with error messages and update error messages when updating language (translation)', () => {
const requiredErrorMessage = {
en: "Error: Oops, this field is required 🙈.",
fr: "Erreur: Oups, ce champ est obligatoire 🙈.",
en: 'Error: Oops, this field is required 🙈.',
fr: 'Erreur: Oups, ce champ est obligatoire 🙈.'
}
cy.get("#error-name").should("not.exist")
cy.get("#error-email").should("not.exist")
cy.get("[data-cy=submit]").click()
cy.get("#error-name").should("have.text", requiredErrorMessage.en)
cy.get("#error-email").should("have.text", requiredErrorMessage.en)
cy.get("[data-cy=language-click]").click()
cy.get("[data-cy=languages-list] > li:first-child").contains("FR").click()
cy.get("#error-name").should("have.text", requiredErrorMessage.fr)
cy.get("#error-email").should("have.text", requiredErrorMessage.fr)
cy.get('#error-name').should('not.exist')
cy.get('#error-email').should('not.exist')
cy.get('[data-cy=submit]').click()
cy.get('#error-name').should('have.text', requiredErrorMessage.en)
cy.get('#error-email').should('have.text', requiredErrorMessage.en)
cy.get('[data-cy=language-click]').click()
cy.get('[data-cy=languages-list] > li:first-child').contains('FR').click()
cy.get('#error-name').should('have.text', requiredErrorMessage.fr)
cy.get('#error-email').should('have.text', requiredErrorMessage.fr)
})
it("fails with invalid name (less than 3 characters)", () => {
cy.get("[data-cy=input-name]").type("a")
cy.get("[data-cy=submit]").click()
cy.get("#error-name").should(
"have.text",
"Error: The field must contain at least 3 characters.",
it('fails with invalid name (less than 3 characters)', () => {
cy.get('[data-cy=input-name]').type('a')
cy.get('[data-cy=submit]').click()
cy.get('#error-name').should(
'have.text',
'Error: The field must contain at least 3 characters.'
)
})
it("fails with invalid name (more than 10 characters)", () => {
cy.get("[data-cy=input-name]").type("12345678910aaaa")
cy.get("[data-cy=submit]").click()
cy.get("#error-name").should(
"have.text",
"Error: The field must contain at most 10 characters.",
it('fails with invalid name (more than 10 characters)', () => {
cy.get('[data-cy=input-name]').type('12345678910aaaa')
cy.get('[data-cy=submit]').click()
cy.get('#error-name').should(
'have.text',
'Error: The field must contain at most 10 characters.'
)
})
it("fails with wrong email format", () => {
cy.get("#error-email").should("not.exist")
cy.get("[data-cy=input-email]").type("test")
cy.get("[data-cy=submit]").click()
cy.get("#error-email").should(
"have.text",
"Error: Mmm… It seems that this email is not valid 🤔.",
it('fails with wrong email format', () => {
cy.get('#error-email').should('not.exist')
cy.get('[data-cy=input-email]').type('test')
cy.get('[data-cy=submit]').click()
cy.get('#error-email').should(
'have.text',
'Error: Mmm… It seems that this email is not valid 🤔.'
)
})
})

View File

@ -1,47 +1,47 @@
describe("Header", () => {
beforeEach(() => cy.visit("/"))
describe('Header', () => {
beforeEach(() => cy.visit('/'))
describe("Switch theme color (dark/light)", () => {
it("should switch theme from `dark` (default) to `light`", () => {
cy.get("[data-cy=switch-theme-dark]").should("be.visible")
cy.get("[data-cy=switch-theme-light]").should("not.be.visible")
cy.get("body").should(
"not.have.css",
"background-color",
"rgb(255, 255, 255)",
describe('Switch theme color (dark/light)', () => {
it('should switch theme from `dark` (default) to `light`', () => {
cy.get('[data-cy=switch-theme-dark]').should('be.visible')
cy.get('[data-cy=switch-theme-light]').should('not.be.visible')
cy.get('body').should(
'not.have.css',
'background-color',
'rgb(255, 255, 255)'
)
cy.get("[data-cy=switch-theme-click]").click()
cy.get('[data-cy=switch-theme-click]').click()
cy.get("[data-cy=switch-theme-dark]").should("not.be.visible")
cy.get("[data-cy=switch-theme-light]").should("be.visible")
cy.get("body").should(
"have.css",
"background-color",
"rgb(255, 255, 255)",
cy.get('[data-cy=switch-theme-dark]').should('not.be.visible')
cy.get('[data-cy=switch-theme-light]').should('be.visible')
cy.get('body').should(
'have.css',
'background-color',
'rgb(255, 255, 255)'
)
})
})
describe("Switch Language", () => {
it("should switch language from EN (default) to FR", () => {
cy.get("[data-cy=main-description]").contains("This is an example")
cy.get("[data-cy=language-flag-text]").contains("EN")
cy.get("[data-cy=languages-list]").should("not.be.visible")
cy.get("[data-cy=language-click]").click()
cy.get("[data-cy=languages-list]").should("be.visible")
cy.get("[data-cy=languages-list] > li:first-child").contains("FR").click()
cy.get("[data-cy=languages-list]").should("not.be.visible")
cy.get("[data-cy=language-flag-text]").contains("FR")
cy.get("[data-cy=main-description]").contains("Ceci est un exemple")
describe('Switch Language', () => {
it('should switch language from EN (default) to FR', () => {
cy.get('[data-cy=main-description]').contains('This is an example')
cy.get('[data-cy=language-flag-text]').contains('EN')
cy.get('[data-cy=languages-list]').should('not.be.visible')
cy.get('[data-cy=language-click]').click()
cy.get('[data-cy=languages-list]').should('be.visible')
cy.get('[data-cy=languages-list] > li:first-child').contains('FR').click()
cy.get('[data-cy=languages-list]').should('not.be.visible')
cy.get('[data-cy=language-flag-text]').contains('FR')
cy.get('[data-cy=main-description]').contains('Ceci est un exemple')
})
it("should close the language list menu when clicking outside", () => {
cy.get("[data-cy=languages-list]").should("not.be.visible")
cy.get("[data-cy=language-click]").click()
cy.get("[data-cy=languages-list]").should("be.visible")
cy.get("[data-cy=main-description]").click()
cy.get("[data-cy=languages-list]").should("not.be.visible")
it('should close the language list menu when clicking outside', () => {
cy.get('[data-cy=languages-list]').should('not.be.visible')
cy.get('[data-cy=language-click]').click()
cy.get('[data-cy=languages-list]').should('be.visible')
cy.get('[data-cy=main-description]').click()
cy.get('[data-cy=languages-list]').should('not.be.visible')
})
})
})

View File

@ -1,45 +1,45 @@
import useTranslation from "next-translate/useTranslation"
import type { Error } from "react-component-form"
import useTranslation from 'next-translate/useTranslation'
import type { Error } from 'react-component-form'
const knownErrorKeywords = ["minLength", "maxLength", "format"]
const knownErrorKeywords = ['minLength', 'maxLength', 'format']
const getErrorTranslationKey = (error: Error): string => {
if (knownErrorKeywords.includes(error?.keyword)) {
if (
error.keyword === "minLength" &&
typeof error.data === "string" &&
error.keyword === 'minLength' &&
typeof error.data === 'string' &&
error.data.length === 0
) {
return "common:required"
return 'common:required'
}
if (error.keyword === "format") {
if (error.params["format"] === "email") {
return "common:invalid-email"
if (error.keyword === 'format') {
if (error.params.format === 'email') {
return 'common:invalid-email'
}
return "common:invalid"
return 'common:invalid'
}
return `common:${error.keyword}`
}
return "common:invalid"
return 'common:invalid'
}
export const useFormTranslation = () => {
const { t } = useTranslation()
const getErrorTranslation = (
error: Error | undefined,
error: Error | undefined
): string | undefined => {
if (error != null) {
return t(getErrorTranslationKey(error)).replace(
"{expected}",
error?.params?.["limit"],
'{expected}',
error?.params?.limit
)
}
return undefined
}
const getFirstErrorTranslation = (
errors: Error[] | undefined,
errors: Error[] | undefined
): string | undefined => {
if (errors != null) {
return getErrorTranslation(errors[0])

View File

@ -1,9 +1,8 @@
import type { Static } from "@sinclair/typebox"
import { Type } from "@sinclair/typebox"
import { Static, Type } from '@sinclair/typebox'
export const userSchema = {
name: Type.String({ minLength: 3, maxLength: 10 }),
email: Type.String({ minLength: 1, maxLength: 254, format: "email" }),
email: Type.String({ minLength: 1, maxLength: 254, format: 'email' })
}
export const userObjectSchema = Type.Object(userSchema)

View File

@ -1,8 +1,8 @@
const nextTranslate = require("next-translate-plugin")
const nextTranslate = require('next-translate')
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
reactStrictMode: true
}
module.exports = nextTranslate(nextConfig)

6890
example/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,32 +7,30 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"test:e2e": "start-server-and-test \"start\" \"http://127.0.0.1:3000\" \"cypress run\"",
"test:dev": "start-server-and-test \"dev\" \"http://127.0.0.1:3000\" \"cypress open\""
"test:e2e": "start-server-and-test \"start\" \"http://localhost:3000\" \"cypress run\"",
"test:dev": "start-server-and-test \"dev\" \"http://localhost:3000\" \"cypress open\""
},
"dependencies": {
"@sinclair/typebox": "0.32.13",
"clsx": "2.1.0",
"next": "13.2.4",
"@sinclair/typebox": "0.24.42",
"clsx": "1.2.1",
"next": "12.3.1",
"next-themes": "0.2.1",
"next-translate": "2.0.5",
"next-translate": "1.6.0",
"react": "18.2.0",
"react-component-form": "file:..",
"react-dom": "18.2.0"
},
"devDependencies": {
"@tsconfig/strictest": "2.0.2",
"@types/node": "20.11.10",
"@types/react": "18.2.48",
"@types/react-dom": "18.2.18",
"autoprefixer": "10.4.17",
"cypress": "13.6.3",
"eslint": "8.56.0",
"eslint-config-next": "13.2.4",
"next-translate-plugin": "2.0.5",
"postcss": "8.4.33",
"start-server-and-test": "2.0.3",
"tailwindcss": "3.4.1",
"typescript": "5.3.3"
"@types/node": "18.7.18",
"@types/react": "18.0.20",
"@types/react-dom": "18.0.6",
"autoprefixer": "10.4.12",
"cypress": "10.8.0",
"eslint": "8.23.1",
"eslint-config-next": "12.3.1",
"postcss": "8.4.16",
"start-server-and-test": "1.14.0",
"tailwindcss": "3.1.8",
"typescript": "4.8.3"
}
}

View File

@ -1,11 +1,11 @@
import type { AppType } from "next/app"
import { ThemeProvider } from "next-themes"
import type { AppType } from 'next/app'
import { ThemeProvider } from 'next-themes'
import "../styles/globals.css"
import '../styles/globals.css'
const MyApp: AppType = ({ Component, pageProps }) => {
return (
<ThemeProvider attribute="class" defaultTheme="dark">
<ThemeProvider attribute='class' defaultTheme='dark'>
<Component {...pageProps} />
</ThemeProvider>
)

View File

@ -1,10 +1,10 @@
import { Html, Head, Main, NextScript } from "next/document"
import { Html, Head, Main, NextScript } from 'next/document'
const Document: React.FC = () => {
return (
<Html>
<Head />
<body className="bg-white text-black dark:bg-black dark:text-white">
<body className='bg-white text-black dark:bg-black dark:text-white'>
<Main />
<NextScript />
</body>

View File

@ -1,21 +1,21 @@
import type { GetStaticProps, NextPage } from "next"
import Head from "next/head"
import type { GetStaticProps, NextPage } from 'next'
import Head from 'next/head'
import { About } from "../components/About"
import { FormExample } from "../components/FormExample"
import { Header } from "../components/Header"
import { About } from '../components/About'
import { FormExample } from '../components/FormExample'
import { Header } from '../components/Header'
const Home: NextPage = () => {
return (
<>
<Head>
<title>react-component-form</title>
<meta name="description" content="Manage React Forms with ease." />
<link rel="icon" href="/favicon.ico" />
<meta name='description' content='Manage React Forms with ease.' />
<link rel='icon' href='/favicon.ico' />
</Head>
<Header />
<main className="flex flex-col justify-center items-center mt-4">
<main className='flex flex-col justify-center items-center mt-4'>
<About />
<FormExample />
</main>

View File

@ -1,6 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
autoprefixer: {}
}
}

View File

@ -1,20 +1,18 @@
/** @type {import('tailwindcss').Config} */
const tailwindConfig = {
module.exports = {
content: [
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
'./pages/**/*.{js,ts,jsx,tsx}',
'./components/**/*.{js,ts,jsx,tsx}'
],
darkMode: "class",
darkMode: 'class',
theme: {
extend: {
colors: {
black: "#212121",
success: "#45C85A",
error: "#C84545",
},
},
black: '#212121',
success: '#45C85A',
error: '#C84545'
}
}
},
plugins: [],
plugins: []
}
module.exports = tailwindConfig

View File

@ -1,21 +1,21 @@
{
"extends": "@tsconfig/strictest/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"lib": ["dom", "dom.iterable", "ESNext"],
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"baseUrl": ".",
"skipLibCheck": true,
"strict": true,
"types": ["cypress"],
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"jsx": "preserve",
"incremental": true,
"exactOptionalPropertyTypes": false,
"verbatimModuleSyntax": false,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"],
"exclude": ["node_modules"]
}

24898
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,76 +4,70 @@
"public": true,
"type": "module",
"description": "Manage React Forms with ease.",
"author": "Théo LUDWIG <contact@theoludwig.fr>",
"author": "Divlo <contact@divlo.fr>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/theoludwig/react-component-form.git"
"url": "https://github.com/Divlo/react-component-form.git"
},
"keywords": [
"react-form",
"react-component-form"
],
"bugs": {
"url": "https://github.com/theoludwig/react-component-form/issues"
"url": "https://github.com/Divlo/react-component-form/issues"
},
"homepage": "https://react-component-form.vercel.app/",
"main": "build/index.js",
"types": "build/index.d.ts",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"build"
"dist"
],
"publishConfig": {
"access": "public",
"provenance": true
},
"scripts": {
"build": "tsup",
"test": "jest",
"lint:commit": "commitlint",
"lint:editorconfig": "editorconfig-checker",
"lint:markdown": "markdownlint-cli2",
"lint:eslint": "eslint . --max-warnings 0 --report-unused-disable-directives --ignore-path .gitignore",
"lint:prettier": "prettier . --check",
"lint:typescript": "eslint \"**/*.{js,jsx,ts,tsx}\"",
"lint:prettier": "prettier \".\" --check --ignore-path \".gitignore\"",
"release": "semantic-release"
},
"peerDependencies": {
"react": ">=18.2.0"
"react": ">=16"
},
"dependencies": {
"@sinclair/typebox": "0.32.13",
"ajv": "8.12.0",
"@sinclair/typebox": "0.24.42",
"ajv": "8.11.0",
"ajv-formats": "2.1.1"
},
"devDependencies": {
"@commitlint/cli": "18.6.0",
"@commitlint/config-conventional": "18.6.0",
"@testing-library/react": "14.1.2",
"@tsconfig/strictest": "2.0.2",
"@types/jest": "29.5.11",
"@types/react": "18.2.48",
"@types/react-dom": "18.2.18",
"@typescript-eslint/eslint-plugin": "6.20.0",
"@typescript-eslint/parser": "6.20.0",
"editorconfig-checker": "5.1.2",
"esbuild": "0.20.0",
"@commitlint/cli": "17.1.2",
"@commitlint/config-conventional": "17.1.0",
"@testing-library/react": "13.4.0",
"@types/jest": "29.0.3",
"@types/react": "18.0.20",
"@types/react-dom": "18.0.6",
"@typescript-eslint/eslint-plugin": "5.38.0",
"@typescript-eslint/parser": "5.38.0",
"editorconfig-checker": "4.0.2",
"esbuild": "0.15.8",
"esbuild-jest": "0.5.0",
"eslint": "8.56.0",
"eslint-config-conventions": "13.1.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-prettier": "5.1.3",
"eslint-plugin-promise": "6.1.1",
"eslint-plugin-unicorn": "50.0.1",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"markdownlint-cli2": "0.12.1",
"markdownlint-rule-relative-links": "2.2.0",
"prettier": "3.2.4",
"eslint": "8.23.1",
"eslint-config-conventions": "4.0.1",
"eslint-config-prettier": "8.5.0",
"eslint-plugin-import": "2.26.0",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-promise": "6.0.1",
"eslint-plugin-unicorn": "43.0.2",
"jest": "29.0.3",
"jest-environment-jsdom": "29.0.3",
"markdownlint-cli2": "0.5.1",
"prettier": "2.7.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"semantic-release": "23.0.0",
"tsup": "8.0.1",
"typescript": "5.3.3"
"semantic-release": "19.0.5",
"tsup": "6.2.3",
"typescript": "4.8.3"
}
}

View File

@ -1,13 +1,13 @@
import React from "react"
import { render, cleanup, fireEvent } from "@testing-library/react"
import React from 'react'
import { render, cleanup, fireEvent } from '@testing-library/react'
import type { HandleForm } from ".."
import { Form } from ".."
import type { HandleForm } from '..'
import { Form } from '..'
afterEach(cleanup)
describe("<Form />", () => {
it("should get the formData and formElement onSubmit and onChange", () => {
describe('<Form />', () => {
it('should get the formData and formElement onSubmit and onChange', () => {
let formData: { [k: string]: any } = {}
let formElement: any = null
const handleSubmitChange: HandleForm = (data, element) => {
@ -16,27 +16,27 @@ describe("<Form />", () => {
}
const formComponent = render(
<Form onSubmit={handleSubmitChange} onChange={handleSubmitChange}>
<input data-testid="input-form" type="text" name="inputName" />
<button data-testid="button-submit" type="submit">
<input data-testid='input-form' type='text' name='inputName' />
<button data-testid='button-submit' type='submit'>
Submit
</button>
</Form>,
</Form>
)
const inputForm = formComponent.getByTestId(
"input-form",
'input-form'
) as HTMLInputElement
const buttonSubmit = formComponent.getByTestId("button-submit")
const text = "some random text"
const buttonSubmit = formComponent.getByTestId('button-submit')
const text = 'some random text'
fireEvent.change(inputForm, { target: { value: text } })
expect(formData["inputName"]).toEqual(text)
expect(formData.inputName).toEqual(text)
expect(formElement instanceof HTMLFormElement).toBeTruthy()
formData = {}
formElement = null
fireEvent.click(buttonSubmit)
expect(Object.keys(formData).length).toEqual(1)
expect(formData["inputName"]).toEqual(text)
expect(formData.inputName).toEqual(text)
expect(formElement instanceof HTMLFormElement).toBeTruthy()
})
})

View File

@ -1,4 +1,4 @@
import React, { useRef } from "react"
import React, { useRef } from 'react'
export interface FormDataObject {
[key: string]: FormDataEntryValue
@ -10,11 +10,11 @@ export interface FormDataObject {
*/
export type HandleForm = (
formData: FormDataObject,
formElement: HTMLFormElement,
formElement: HTMLFormElement
) => void | Promise<void>
interface ReactFormProps
extends Omit<React.HTMLProps<HTMLFormElement>, "onSubmit" | "onChange"> {}
extends Omit<React.HTMLProps<HTMLFormElement>, 'onSubmit' | 'onChange'> {}
export interface FormProps extends ReactFormProps {
onSubmit?: HandleForm
@ -22,7 +22,7 @@ export interface FormProps extends ReactFormProps {
}
export const getFormDataObject = (
formElement: HTMLFormElement,
formElement: HTMLFormElement
): FormDataObject => {
return Object.fromEntries<FormDataEntryValue>(new FormData(formElement))
}

View File

@ -1,14 +1,14 @@
import { useState } from "react"
import { useState } from 'react'
export const fetchState = ["idle", "loading", "error", "success"] as const
export const fetchState = ['idle', 'loading', 'error', 'success'] as const
export type FetchState = (typeof fetchState)[number]
export type FetchState = typeof fetchState[number]
export const useFetchState = (
initialFetchState: FetchState = "idle",
initialFetchState: FetchState = 'idle'
): [
fetchState: FetchState,
setFetchState: React.Dispatch<React.SetStateAction<FetchState>>,
setFetchState: React.Dispatch<React.SetStateAction<FetchState>>
] => {
const [fetchState, setFetchState] = useState<FetchState>(initialFetchState)
return [fetchState, setFetchState]

View File

@ -1,17 +1,17 @@
import { useMemo, useState } from "react"
import type { Static, TObject } from "@sinclair/typebox"
import { Type } from "@sinclair/typebox"
import type { ErrorObject } from "ajv"
import { useMemo, useState } from 'react'
import type { Static, TObject } from '@sinclair/typebox'
import { Type } from '@sinclair/typebox'
import type { ErrorObject } from 'ajv'
import type { HandleForm } from "../components/Form"
import type { FetchState } from "./useFetchState"
import { useFetchState } from "./useFetchState"
import { ajv } from "../utils/ajv"
import { handleCheckboxBoolean } from "../utils/handleCheckboxBoolean"
import { handleOptionalEmptyStringToNull } from "../utils/handleOptionalEmptyStringToNull"
import type { HandleForm } from '../components/Form'
import type { FetchState } from './useFetchState'
import { useFetchState } from './useFetchState'
import { ajv } from '../utils/ajv'
import { handleCheckboxBoolean } from '../utils/handleCheckboxBoolean'
import { handleOptionalEmptyStringToNull } from '../utils/handleOptionalEmptyStringToNull'
export interface Schema {
[property: string | symbol]: any
[property: string]: any
}
export type Error = ErrorObject
@ -29,22 +29,22 @@ export type HandleUseFormCallbackResult<K extends Schema> = Message<K> | null
*/
export type HandleUseFormCallback<K extends Schema> = (
formData: Static<TObject<K>>,
formElement: HTMLFormElement,
formElement: HTMLFormElement
) => Promise<HandleUseFormCallbackResult<K>> | HandleUseFormCallbackResult<K>
export type HandleUseForm<K extends Schema> = (
callback?: HandleUseFormCallback<K>,
callback?: HandleUseFormCallback<K>
) => HandleForm
export interface GlobalMessage {
type: "error" | "success"
message?: string
type: 'error' | 'success'
value?: string
properties?: undefined
}
export interface PropertiesMessage<K extends Schema> {
type: "error"
message?: string
type: 'error'
value?: string
properties: { [key in keyof Partial<K>]: string }
}
@ -65,7 +65,7 @@ export interface UseFormResult<K extends Schema> {
/**
* Global message of the form (not specific to a property).
*/
readonly message: string | undefined
readonly message?: string
setMessage: React.Dispatch<React.SetStateAction<string | undefined>>
/**
@ -81,7 +81,7 @@ export interface UseFormResult<K extends Schema> {
}
export const useForm = <K extends Schema>(
validationSchema: K,
validationSchema: K
): UseFormResult<typeof validationSchema> => {
const validationSchemaObject = useMemo(() => {
return Type.Object(validationSchema)
@ -90,7 +90,7 @@ export const useForm = <K extends Schema>(
const [fetchState, setFetchState] = useFetchState()
const [message, setMessage] = useState<string | undefined>(undefined)
const [errors, setErrors] = useState<ErrorsObject<typeof validationSchema>>(
{} as any,
{} as any
)
const validate = useMemo(() => {
@ -103,12 +103,12 @@ export const useForm = <K extends Schema>(
setMessage(undefined)
formData = handleOptionalEmptyStringToNull(
formData,
validationSchemaObject.required,
validationSchemaObject.required
)
formData = handleCheckboxBoolean(formData, validationSchemaObject)
const isValid = validate(formData)
if (!isValid) {
setFetchState("error")
setFetchState('error')
const errors: ErrorsObject<typeof validationSchema> = {} as any
for (const property in validationSchemaObject.properties) {
const errorsForProperty = validate.errors?.filter((error) => {
@ -123,28 +123,28 @@ export const useForm = <K extends Schema>(
} else {
setErrors({} as any)
if (callback != null) {
setFetchState("loading")
setFetchState('loading')
const message = await callback(
formData as Static<TObject<typeof validationSchema>>,
formElement,
formElement
)
if (message != null) {
const { message: messageValue, type, properties } = message
setMessage(messageValue)
const { value, type, properties } = message
setMessage(value)
setFetchState(type)
if (type === "error") {
if (type === 'error') {
const propertiesErrors: ErrorsObject<typeof validationSchema> =
{} as any
for (const property in properties) {
propertiesErrors[property] = [
{
keyword: "message",
keyword: 'message',
message: properties[property],
instancePath: `/${property}`,
schemaPath: `#/properties/${property}/message`,
params: {},
data: formData[property],
},
data: formData[property]
}
]
}
setErrors(propertiesErrors)
@ -161,6 +161,6 @@ export const useForm = <K extends Schema>(
setFetchState,
message,
setMessage,
errors,
errors
}
}

View File

@ -1,4 +1,4 @@
export * from "./components/Form"
export * from "./hooks/useFetchState"
export * from "./hooks/useForm"
export * from "./utils/ajv"
export * from './components/Form'
export * from './hooks/useFetchState'
export * from './hooks/useForm'
export * from './utils/ajv'

View File

@ -1,25 +1,25 @@
import addFormats from "ajv-formats"
import Ajv from "ajv"
import addFormats from 'ajv-formats'
import Ajv from 'ajv'
export const ajv = addFormats(
new Ajv({
allErrors: true,
verbose: true,
verbose: true
}),
[
"date-time",
"time",
"date",
"email",
"hostname",
"ipv4",
"ipv6",
"uri",
"uri-reference",
"uuid",
"uri-template",
"json-pointer",
"relative-json-pointer",
"regex",
],
'date-time',
'time',
'date',
'email',
'hostname',
'ipv4',
'ipv6',
'uri',
'uri-reference',
'uuid',
'uri-template',
'json-pointer',
'relative-json-pointer',
'regex'
]
)

View File

@ -1,15 +1,15 @@
import type { TObject } from "@sinclair/typebox"
import type { TObject } from '@sinclair/typebox'
import type { Schema } from "../hooks/useForm"
import type { ObjectAny } from './types'
export const handleCheckboxBoolean = (
object: Schema,
validateSchemaObject: TObject<Schema>,
): Schema => {
object: ObjectAny,
validateSchemaObject: TObject<ObjectAny>
): ObjectAny => {
const booleanProperties: string[] = []
for (const property in validateSchemaObject.properties) {
const rule = validateSchemaObject.properties[property]
if (rule.type === "boolean") {
if (rule.type === 'boolean') {
booleanProperties.push(property)
}
}
@ -18,7 +18,7 @@ export const handleCheckboxBoolean = (
object[booleanProperty] =
validateSchemaObject.properties[booleanProperty].default
} else {
object[booleanProperty] = object[booleanProperty] === "on"
object[booleanProperty] = object[booleanProperty] === 'on'
}
}
return object

View File

@ -1,19 +1,17 @@
import type { Schema } from "../hooks/useForm"
export const handleOptionalEmptyStringToNull = <K extends Schema>(
export const handleOptionalEmptyStringToNull = <K>(
object: K,
required: string[] = [],
required: string[] = []
): K => {
return Object.fromEntries(
Object.entries(object).map(([key, value]) => {
if (
typeof value === "string" &&
typeof value === 'string' &&
value.length === 0 &&
!required.includes(key)
) {
return [key, null]
}
return [key, value]
}),
})
) as K
}

3
src/utils/types.ts Normal file
View File

@ -0,0 +1,3 @@
export interface ObjectAny {
[key: string]: any
}

View File

@ -1,5 +1,4 @@
{
"extends": "@tsconfig/strictest/tsconfig.json",
"include": ["src", "types"],
"compilerOptions": {
"module": "ESNext",
@ -8,8 +7,16 @@
"declaration": true,
"sourceMap": true,
"rootDir": "./src",
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"moduleResolution": "node",
"jsx": "react",
"noEmit": true,
},
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true
}
}

View File

@ -1,13 +1,12 @@
import { defineConfig } from "tsup"
import { defineConfig } from 'tsup'
export default defineConfig({
entry: ["src/**/*.{ts,tsx}", "!src/**/*.test.{ts,tsx}"],
sourcemap: false,
entry: ['src/index.ts'],
sourcemap: true,
clean: true,
platform: "browser",
target: "esnext",
format: ["esm"],
minify: false,
outDir: "build",
dts: true,
platform: 'browser',
target: 'esnext',
format: ['esm'],
minify: true,
dts: true
})