diff --git a/.env.example b/.env.example
index 02c1ed2ae..1071ebab7 100644
--- a/.env.example
+++ b/.env.example
@@ -5,32 +5,27 @@ DOCKER_DATABASE_USERNAME="startui"
DOCKER_DATABASE_PASSWORD="startui"
# PUBLIC CONFIG
-NEXT_PUBLIC_BASE_URL="http://localhost:3000"
+VITE_BASE_URL="http://localhost:3000"
# Use the following environment variables to show the environment name.
-NEXT_PUBLIC_ENV_NAME="LOCAL"
-NEXT_PUBLIC_ENV_EMOJI="๐ง"
-NEXT_PUBLIC_ENV_COLOR_SCHEME="warning"
+VITE_ENV_NAME="LOCAL"
+VITE_ENV_EMOJI="๐ง"
+VITE_ENV_COLOR="gold"
+
# Configure demo mode (read only)
-NEXT_PUBLIC_IS_DEMO="false"
+VITE_IS_DEMO="false"
# DATABASE
DATABASE_URL="postgres://${DOCKER_DATABASE_USERNAME}:${DOCKER_DATABASE_PASSWORD}@localhost:${DOCKER_DATABASE_PORT}/${DOCKER_DATABASE_NAME}"
-# SESSION
-SESSION_EXPIRATION_SECONDS=2592000 # 30 days
+# AUTH
+BETTER_AUTH_SECRET="REPLACE ME" # You can use `npx @better-auth/cli@latest secret` to a generated secret
+SESSION_EXPIRATION_IN_SECONDS=2592000 # 30 days
+SESSION_UPDATE_AGE_IN_SECONDS=86400 # 1 day (every 1 day the session expiration is updated)
# GITHUB
GITHUB_CLIENT_ID="REPLACE ME"
GITHUB_CLIENT_SECRET="REPLACE ME"
-# GOOGLE
-GOOGLE_CLIENT_ID="REPLACE ME"
-GOOGLE_CLIENT_SECRET="REPLACE ME"
-
-# DISCORD
-DISCORD_CLIENT_ID="REPLACE ME"
-DISCORD_CLIENT_SECRET="REPLACE ME"
-
# EMAILS
EMAIL_SERVER="smtp://username:password@0.0.0.0:1025"
EMAIL_FROM="Start UI "
diff --git a/.eslintrc.json b/.eslintrc.json
deleted file mode 100644
index 965d8d893..000000000
--- a/.eslintrc.json
+++ /dev/null
@@ -1,38 +0,0 @@
-{
- "extends": [
- "plugin:@typescript-eslint/recommended",
- "plugin:sonarjs/recommended",
- "next/core-web-vitals",
- "plugin:storybook/recommended"
- ],
- "rules": {
- "react/no-unescaped-entities": "off",
- "@typescript-eslint/no-unused-vars": [
- "warn",
- {
- "argsIgnorePattern": "^_"
- }
- ],
- "sonarjs/no-duplicate-string": "off",
- "sonarjs/cognitive-complexity": ["warn", 50],
- "sonarjs/prefer-immediate-return": "warn"
- },
- "overrides": [
- {
- "files": ["./src/**/*.*"],
- "rules": {
- "no-process-env": "error"
- }
- },
- {
- "files": [
- "*.stories.tsx",
- "./src/locales/**/*",
- "./src/theme/components/**/*.ts"
- ],
- "rules": {
- "import/no-anonymous-default-export": "off"
- }
- }
- ]
-}
diff --git a/.github/assets/tech-logos.png b/.github/assets/tech-logos.png
index cbae85e06..df31adc2d 100644
Binary files a/.github/assets/tech-logos.png and b/.github/assets/tech-logos.png differ
diff --git a/.github/assets/thumbnail.png b/.github/assets/thumbnail.png
index c3af161c3..33ddf940d 100644
Binary files a/.github/assets/thumbnail.png and b/.github/assets/thumbnail.png differ
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
deleted file mode 100644
index 09b22b997..000000000
--- a/.github/pull_request_template.md
+++ /dev/null
@@ -1,30 +0,0 @@
-## Describe your changes
-
-
-closes #
-
-
-
-## Screenshots
-
-
-
-## Documentation
-
-
-
-## Checklist
-
- - [ ] I performed a self review of my code
- - [ ] I ensured that everything is written in English
- - [ ] I tested the feature or fix on my local environment
- - [ ] I ran the `pnpm storybook` command and everything is working
- - [ ] If applicable, I updated the translations for english and french files
- (If you cannot update the french language, just let us know in the PR description)
- - [ ] If applicable, I updated the README.md
- - [ ] If applicable, I created a PR or an issue on the [documentation repository](https://github.com/bearstudio/start-ui-web-docs/)
- - [ ] If applicable, Iโm sure that my feature or my component is mobile first and available correctly on desktop
-
-
-
-
diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml
index dd70ca15b..0c5fe7357 100644
--- a/.github/workflows/code-quality.yml
+++ b/.github/workflows/code-quality.yml
@@ -2,61 +2,84 @@ name: ๐ Code Quality
on:
push:
- branches:
- - master
- - main
- - develop
- - staging
+ branches: [main]
pull_request:
jobs:
- lint-code:
+ linter:
+ name: ๐งน Linter
timeout-minutes: 10
- name: Lint and Type Check
runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Install pnpm
+ uses: pnpm/action-setup@v2
+ with:
+ version: 10
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 'lts/*'
+ cache: 'pnpm'
+
+ - name: Install deps
+ run: pnpm install
+ - name: Run eslint
+ run: pnpm run lint:eslint
+
+ typescriptChecker:
+ name: ๐ฆ TypeScript Checker
+ timeout-minutes: 10
+ runs-on: ubuntu-latest
strategy:
matrix:
- node: [20, 22, 'lts/*']
-
+ node: [20, 22, 24, 'lts/*']
steps:
- uses: actions/checkout@v3
- - uses: actions/setup-node@v2
+ - name: Install pnpm
+ uses: pnpm/action-setup@v2
with:
- node-version: ${{ matrix.node }}
+ version: 10
- - uses: pnpm/action-setup@v4
- name: Install pnpm
- with:
- version: 8
- run_install: false
-
- - name: Get pnpm store directory
- shell: bash
- run: |
- echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
-
- - name: Cache node modules
- uses: actions/cache@v4
- env:
- cache-name: cache-node-modules
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
with:
- path: ${{ env.STORE_PATH }}
- key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
- restore-keys: |
- ${{ runner.os }}-pnpm-store-${{ env.cache-name }}-
- ${{ runner.os }}-pnpm-store-
- ${{ runner.os }}-
-
- - name: Install dependencies
+ node-version: ${{ matrix.node }}
+ cache: 'pnpm'
+
+ - name: Install deps
run: pnpm install
- - name: NextJS Linting
- run: pnpm lint:next
+ - name: Run Typescript checker
+ run: pnpm run lint:ts
+
+ tests:
+ name: ๐ฌ Tests
+ timeout-minutes: 10
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ node: [20, 22, 24, 'lts/*']
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Install pnpm
+ uses: pnpm/action-setup@v2
+ with:
+ version: 10
- - name: TypeScript check
- run: pnpm lint:ts
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node }}
+ cache: 'pnpm'
+
+ - name: Install deps
+ run: pnpm install
- name: Run tests
- run: pnpm test:ci
+ run: pnpm run test:ci
diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml
index d9026ad85..959303cca 100644
--- a/.github/workflows/e2e-tests.yml
+++ b/.github/workflows/e2e-tests.yml
@@ -6,12 +6,8 @@ on:
- main
- develop
- staging
+ - ivan/restart # TODO remove me on final merge on main
pull_request:
- branches:
- - master
- - main
- - develop
- - staging
jobs:
E2E:
@@ -19,11 +15,11 @@ jobs:
timeout-minutes: 30
runs-on: ubuntu-latest
env:
- NEXT_PUBLIC_BASE_URL: http://localhost:3000
- NEXT_PUBLIC_ENV_NAME: tests
+ VITE_BASE_URL: http://localhost:3000
+ VITE_ENV_NAME: tests
DATABASE_URL: postgres://startui:startui@localhost:5432/startui
- NEXT_PUBLIC_IS_DEMO: false
- AUTH_SECRET: Replace me with `openssl rand -base64 32` generated secret
+ VITE_IS_DEMO: false
+ BETTER_AUTH_SECRET: fyXjLxjXJowjicI2BAjxOaUsQd1QafdegZDciJE/xM8=
EMAIL_SERVER: smtp://username:password@localhost:1025
EMAIL_FROM: Start UI
services:
diff --git a/.gitignore b/.gitignore
index 87e39a5d6..a206ca962 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,12 +8,9 @@
# testing
/coverage
-# next.js
-/.next/
-/out/
-next-env.d.ts
-
# production
+.output
+.vinxi
/build
/public/storybook
/storybook-static
@@ -23,6 +20,7 @@ next-env.d.ts
*.pem
/.vscode/*
!/.vscode/extensions.json
+!/.vscode/settings.example.json
.idea/
.eslintcache
.db
@@ -44,7 +42,7 @@ yarn-error.log*
.vercel
# Build info
-.build-info.json
+build-info.gen.json
# Prisma
prisma/dev.db
@@ -58,9 +56,7 @@ prisma/dev.db-journal
/playwright-report/
/playwright/.cache/
-# next-pwa recommandation
-**/public/workbox-*.js*
-**/public/sw.js*
-**/public/worker-development.js*
+certificates
-certificates
\ No newline at end of file
+*storybook.log
+app.config.timestamp_*.js
diff --git a/.husky/.gitignore b/.husky/.gitignore
deleted file mode 100644
index 31354ec13..000000000
--- a/.husky/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-_
diff --git a/.husky/pre-commit b/.husky/pre-commit
deleted file mode 100755
index c27d8893a..000000000
--- a/.husky/pre-commit
+++ /dev/null
@@ -1 +0,0 @@
-lint-staged
diff --git a/.husky/pre-push b/.husky/pre-push
deleted file mode 100644
index 009b3f89b..000000000
--- a/.husky/pre-push
+++ /dev/null
@@ -1 +0,0 @@
-pnpm lint
diff --git a/.npmrc b/.npmrc
index b6f27f135..550b6dd93 100644
--- a/.npmrc
+++ b/.npmrc
@@ -1 +1,3 @@
engine-strict=true
+# otherwise the postinstall script of the lefthook package won't be executed and hooks won't be installed
+side-effects-cache = false
diff --git a/.prettierignore b/.prettierignore
index d400b04a8..ca442df9e 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,8 +1,8 @@
.cache
-.next
.db
.history
-out
+output
+.vinxi
node_modules
package.json
package-lock.json
@@ -13,3 +13,4 @@ public
.env*
!.env.validator.js
pnpm-lock.yaml
+*.gen.ts
diff --git a/.prettierrc.js b/.prettierrc.cjs
similarity index 58%
rename from .prettierrc.js
rename to .prettierrc.cjs
index 00ab3b42f..59ebd3d60 100644
--- a/.prettierrc.js
+++ b/.prettierrc.cjs
@@ -2,17 +2,17 @@
// But if you do so, please run the `npm run pretty` command.
/** @type {import("prettier").Options} */
const config = {
+ plugins: ['prettier-plugin-tailwindcss'],
endOfLine: 'lf',
semi: true,
singleQuote: true,
tabWidth: 2,
trailingComma: 'es5',
arrowParens: 'always',
- importOrder: ['^react$', '^(?!^react$|^@/|^[./]).*', '^@/(.*)$', '^[./]'],
- importOrderSeparation: true,
- importOrderSortSpecifiers: true,
- importOrderParserPlugins: ['jsx', 'typescript'],
- plugins: ['@trivago/prettier-plugin-sort-imports'],
+
+ tailwindStylesheet: './app/styles/app.css',
+ tailwindFunctions: ['cn', 'cva'],
};
+// eslint-disable-next-line no-undef
module.exports = config;
diff --git a/.storybook/main.ts b/.storybook/main.ts
index c0fbe82d3..a7f815125 100644
--- a/.storybook/main.ts
+++ b/.storybook/main.ts
@@ -1,32 +1,29 @@
-import { StorybookConfig } from '@storybook/nextjs';
+import type { StorybookConfig } from '@storybook/react-vite';
+import path from 'node:path';
+import tsconfigPaths from 'vite-tsconfig-paths';
const config: StorybookConfig = {
- framework: {
- name: '@storybook/nextjs',
- options: {},
- },
-
- stories: ['../src/**/*.@(mdx|stories.@(js|jsx|ts|tsx))'],
-
+ stories: ['../stories/**/*.mdx', '../**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
- '@storybook/addon-links',
- '@storybook/addon-essentials',
- 'storybook-dark-mode',
+ '@vueless/storybook-dark-mode',
+ '@storybook/addon-a11y',
+ '@storybook/addon-docs',
],
-
- staticDirs: ['../public'],
-
- docs: {},
-
- typescript: {
- reactDocgen: 'react-docgen-typescript',
+ framework: {
+ name: '@storybook/react-vite',
+ options: {},
},
-
- refs: {
- // Disable Chakra UI storybook composition as it fetches the v3.
- // I did not find the URL of the v2 at the moment to replace it.
- '@chakra-ui/react': { disable: true },
+ viteFinal: async (config) => {
+ if (!config.plugins) {
+ config.plugins = [];
+ }
+ config.plugins.push(
+ /** @see https://github.com/aleclarson/vite-tsconfig-paths */
+ tsconfigPaths({
+ projects: [path.resolve(path.dirname(__dirname), 'tsconfig.json')],
+ })
+ );
+ return config;
},
};
-
export default config;
diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html
deleted file mode 100644
index 274c4eee8..000000000
--- a/.storybook/preview-head.html
+++ /dev/null
@@ -1,16 +0,0 @@
-
diff --git a/.storybook/preview.css b/.storybook/preview.css
new file mode 100644
index 000000000..579be052c
--- /dev/null
+++ b/.storybook/preview.css
@@ -0,0 +1,16 @@
+body,
+.docs-story {
+ background-color: var(--background);
+}
+
+#preview-container {
+ min-height: 10rem;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ width: 100%;
+}
+
+#storybook-root #preview-container {
+ min-height: 100vh;
+}
diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
index 6a837a649..fe690f419 100644
--- a/.storybook/preview.tsx
+++ b/.storybook/preview.tsx
@@ -1,86 +1,75 @@
-import React, { useEffect } from 'react';
+import { useEffect } from 'react';
-import { Box, useColorMode } from '@chakra-ui/react';
-import { Preview } from '@storybook/react';
-import { themes } from '@storybook/theming';
+import type { Preview } from '@storybook/react-vite';
+import { useDarkMode } from '@vueless/storybook-dark-mode';
+import { useTheme } from 'next-themes';
import { useTranslation } from 'react-i18next';
-import { useDarkMode } from 'storybook-dark-mode';
-import { Providers } from '../src/app/Providers';
-import i18nGlobal from '../src/lib/i18n/client';
+import '@/styles/app.css';
+
import {
AVAILABLE_LANGUAGES,
DEFAULT_LANGUAGE_KEY,
-} from '../src/lib/i18n/constants';
-// @ts-ignore don't want to implement a d.ts declaration for storybook only
-import logoReversed from './logo-reversed.svg';
-// @ts-ignore don't want to implement a d.ts declaration for storybook only
-import logo from './logo.svg';
+} from '../app/lib/i18n/constants';
+import i18nGlobal from '../app/lib/i18n/index';
+import { Providers } from '../app/providers';
+import './preview.css';
-const DocumentationWrapper = ({ children, context, isDarkMode }) => {
+const DocumentationWrapper = ({ children, isDarkMode, context }) => {
const { i18n } = useTranslation();
- const { colorMode, setColorMode } = useColorMode();
+ const { setTheme } = useTheme();
// Update color mode
useEffect(() => {
- // Add timeout to prevent unsync color mode between docs and classic modes
- const timer = setTimeout(() => {
- if (isDarkMode) {
- setColorMode('dark');
- } else {
- setColorMode('light');
- }
- });
- return () => clearTimeout(timer);
- }, [isDarkMode]);
+ setTheme(isDarkMode ? 'dark' : 'light');
+ }, [isDarkMode, setTheme]);
// Update language
useEffect(() => {
i18n.changeLanguage(context.globals.locale);
+ const languageConfig = AVAILABLE_LANGUAGES.find(
+ ({ key }) => key === context.globals.locale
+ );
+ if (languageConfig) {
+ document.documentElement.lang = languageConfig.key;
+ document.documentElement.dir = languageConfig.dir ?? 'ltr';
+ document.documentElement.style.fontSize = `${(languageConfig.fontScale ?? 1) * 100}%`;
+ }
}, [context.globals.locale]);
- return (
-
- {children}
-
- );
+ return {children}
;
};
const preview: Preview = {
- tags: ['autodocs'],
+ parameters: {
+ layout: 'padded',
+ backgrounds: {
+ disable: true,
+ grid: { disable: true },
+ },
+ darkMode: {
+ stylePreview: true,
+ },
+ docs: {
+ codePanel: true,
+ },
+ },
+ initialGlobals: {
+ locale: DEFAULT_LANGUAGE_KEY,
+ },
globalTypes: {
locale: {
name: 'Locale',
description: 'Internationalization locale',
- defaultValue: DEFAULT_LANGUAGE_KEY,
toolbar: {
icon: 'globe',
items: AVAILABLE_LANGUAGES.map(({ key }) => ({
value: key,
- title: i18nGlobal.t(`languages.${String(key)}`),
+ title: i18nGlobal.t(`languages.${String(key)}`, { lng: 'en' }),
})),
},
},
},
- parameters: {
- options: {
- storySort: {
- order: ['StyleGuide', 'Components', 'Fields', 'App Layout'],
- },
- },
- darkMode: {
- dark: themes.dark,
- light: themes.light,
- },
- layout: 'fullscreen',
- backgrounds: { disable: true, grid: { disable: true } },
- },
decorators: [
(story, context) => {
const isDarkMode = useDarkMode();
diff --git a/.vscode/settings.example.json b/.vscode/settings.example.json
new file mode 100644
index 000000000..c512dc854
--- /dev/null
+++ b/.vscode/settings.example.json
@@ -0,0 +1,23 @@
+{
+ "files.associations": {
+ "*.css": "tailwindcss"
+ },
+ "tailwindCSS.experimental.classRegex": [
+ ["cva\\(((?:[^()]|\\([^()]*\\))*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
+ ["cn\\(((?:[^()]|\\([^()]*\\))*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
+ ],
+ "i18n-ally.localesPaths": [
+ "app/locales",
+ "node_modules/zod-i18n-map/locales"
+ ],
+ "i18n-ally.keystyle": "nested",
+ "i18n-ally.enabledFrameworks": ["general", "react", "i18next"],
+ "i18n-ally.namespace": true,
+ "i18n-ally.defaultNamespace": "common",
+ "i18n-ally.extract.autoDetect": true,
+ "i18n-ally.keysInUse": ["common.languages.*"],
+ "files.readonlyInclude": {
+ "**/*.gen.ts": true,
+ "**/generated/**/*": true
+ }
+}
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index c511f377e..000000000
--- a/Dockerfile
+++ /dev/null
@@ -1,38 +0,0 @@
-FROM node:20-alpine AS base
-# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
-RUN apk add --no-cache libc6-compat
-RUN apk add --no-cache git
-ENV PNPM_HOME="/pnpm"
-ENV PATH="$PNPM_HOME:$PATH"
-# Enable supported package managers
-RUN corepack enable
-WORKDIR /app
-COPY . .
-
-FROM base AS build
-# We still need devDependencies to build Start UI [web], so no --prod option
-RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
-# Building as a production application though
-ENV NODE_ENV production
-RUN pnpm run build
-
-FROM base AS runner
-WORKDIR /app
-RUN addgroup --system --gid 1001 nodejs
-RUN adduser --system --uid 1001 nextjs
-RUN mkdir .next
-RUN chown nextjs:nodejs .next
-COPY --from=build /app/node_modules /app/node_modules
-COPY --from=build /app/.next /app/.next
-
-USER nextjs
-WORKDIR /app
-
-EXPOSE 3000
-
-ENV PORT 3000
-
-# Learn more here: https://nextjs.org/telemetry
-ENV NEXT_TELEMETRY_DISABLED 1
-
-CMD ["pnpm", "start"]
diff --git a/README.md b/README.md
index 440bf9989..8f3a1494f 100644
--- a/README.md
+++ b/README.md
@@ -1,199 +1,51 @@
-[](https://go.bearstudio.fr/discord)
-
๐ Start UI [web] is an opinionated frontend starter repository created & maintained by the [BearStudio Team](https://www.bearstudio.fr/team) and other contributors.
It represents our team's up-to-date stack that we use when creating web apps for our clients.
-## Documentation
-
-For detailed information on how to use this project, please refer to the [documentation](https://docs.web.start-ui.com). The documentation contains all the necessary information on installation, usage, and some guides.
-
-## Demo
-A live read-only demonstration of what you will have when starting a project with ๐ Start UI [web] is available on [demo.start-ui.com](https://demo.start-ui.com).
-
-## Technologies
+# Technologies
-[๐ฆ TypeScript](https://www.typescriptlang.org/), [โ๏ธ React](https://react.dev/), [โซ๏ธ NextJS](https://nextjs.org/), [โก๏ธ Chakra UI](https://chakra-ui.com/), [๐ฆ tRPC](https://trpc.io/), [โฒ Prisma](https://www.prisma.io/), [๐๏ธ TanStack Query](https://react-query.tanstack.com/), [๐ Storybook](https://storybook.js.org/), [๐ญ Playwright](https://playwright.dev/), [๐ React Hook Form](https://react-hook-form.com/)
-, [๐ React i18next](https://react.i18next.com/)
-
-
-## Requirements
-
-- [NodeJS](https://nodejs.org/) >=20
-- [Pnpm](https://pnpm.io/)
-- [Docker](https://www.docker.com/) (or a [PostgreSQL](https://www.postgresql.org/) database)
-
-## Getting Started
+# Getting Started
```bash
-pnpm create start-ui --web myApp
+pnpm create start-ui -t web -b v3-main myApp
```
That will scaffold a new folder with the latest version of ๐ Start UI [web] ๐
-## Installation
+# Install
-1. Duplicate the `.env.example` file to a new `.env` file, and update the environment variables
-
-```bash
-cp .env.example .env
```
-
-> [!NOTE]
-> **Quick advices for local development**
-> - **DON'T update** the **EMAIL_SERVER** variable, because the default value will be used to catch the emails during the development.
-
-
-2. Install dependencies
-```bash
-pnpm install
-```
-
-3. Setup and start the db with docker
-```bash
-pnpm dk:init
-```
-> [!NOTE]
-> **Don't want to use docker?**
->
-> Setup a PostgreSQL database (locally or online) and replace the **DATABASE_URL** environment variable. Then you can run `pnpm db:push` to update your database schema and then run `pnpm db:seed` to seed your database.
-
-## Development
-
-```bash
-# Run the database in Docker (if not already started)
-pnpm dk:start
-# Run the development server
-pnpm dev
+cp .env.example .env # Setup your env variables
+cp .vscode/settings.example.json .vscode/settings.json # (Optionnal) Setup your VS Code
+pnpm install # Install dependencies
+pnpm dk:init # Init docker
+pnpm db:init # Init the db
```
-### Emails in development
-
-#### Maildev to catch emails
-
-In development, the emails will not be sent and will be catched by [maildev](https://github.com/maildev/maildev).
-
-The maildev UI is available at [0.0.0.0:1080](http://0.0.0.0:1080).
-
-#### Preview emails
-
-Emails templates are built with `react-email` components in the `src/emails` folder.
-
-You can preview an email template at `http://localhost:3000/devtools/email/{template}` where `{template}` is the name of the template file in the `src/emails/templates` folder.
-
-Example: [Login Code](http://localhost:3000/devtools/email/login-code)
+# Run
-##### Email translation preview
-
-Add the language in the preview url like `http://localhost:3000/devtools/email/{template}/{language}` where `{language}` is the language key (`en`, `fr`, ...)
-
-#### Email props preview
-
-You can add search params to the preview url to pass as props to the template.
-`http://localhost:3000/devtools/email/{template}/?{propsName}={propsValue}`
-
-### Storybook
-
-```bash
-pnpm storybook
```
-
-### Update theme typing
-
-When adding or updating theme components, component variations, sizes, colors and other theme foundations, you can extend the internal theme typings to provide nice autocomplete.
-
-Just run the following command after updating the theme:
-
-```bash
-pnpm theme:generate-typing
-```
-
-### Generate custom icons components from svg files
-
-Put the custom svg files into the `src/components/Icons/svg-sources` folder and then run the following command:
-
-```bash
-pnpm theme:generate-icons
-```
-
-> [!WARNING]
-> All svg icons should be svg files prefixed by `icon-` (example: `icon-externel-link`) with **24x24px** size, only **one shape** and **filled with `#000` color** (will be replaced by `currentColor`).
-
-
-### Update color mode storage key
-
-You can update the storage key used to detect the color mode by updating this constant in the `src/theme/config.ts` file:
-
-```tsx
-export const COLOR_MODE_STORAGE_KEY = 'start-ui-color-mode'; // Update the key according to your needs
-```
-
-### E2E Tests
-
-E2E tests are setup with Playwright.
-
-```sh
-pnpm e2e # Run tests in headless mode, this is the command executed in CI
-pnpm e2e:ui # Open a UI which allow you to run specific tests and see test execution
-```
-
-Tests are written in the `e2e` folder; there is also a `e2e/utils` folder which contains some utils to help writing tests.
-
-## Show hint on development environments
-
-Setup the `NEXT_PUBLIC_ENV_NAME` env variable with the name of the environment.
-
-```
-NEXT_PUBLIC_ENV_NAME="staging"
-NEXT_PUBLIC_ENV_EMOJI="๐ฌ"
-NEXT_PUBLIC_ENV_COLOR_SCHEME="teal"
-```
-
-## Translations
-
-### Setup the i18n Ally extension
-
-We recommended using the [i18n Ally](https://marketplace.visualstudio.com/items?itemName=lokalise.i18n-ally) plugin for VS Code for translations management.
-
-Create or edit the `.vscode/settings.json` file with the following settings:
-
-```json
-{
- "i18n-ally.localesPaths": [
- "src/locales",
- "node_modules/zod-i18n-map/locales"
- ],
- "i18n-ally.keystyle": "nested",
- "i18n-ally.enabledFrameworks": ["general", "react", "i18next"],
- "i18n-ally.namespace": true,
- "i18n-ally.defaultNamespace": "common",
- "i18n-ally.extract.autoDetect": true,
- "i18n-ally.keysInUse": ["common.languages.*"]
-}
+pnpm dk:start # Only if your docker is not running
+pnpm dev
```
-## Production
-```bash
-pnpm install
-pnpm storybook:build # Optional: Will expose the Storybook at `/storybook`
-pnpm build
-pnpm start
-```
+# FAQ
-### Deploy with Docker
+git detect a lot of changes inside my .husky
folder
+
+You probably have updated your branch with lefthook installed instead of husky. Follow these steps to fix
+your hooks issue:
+
+ git config --unset core.hooksPath
+ rm -rf ./.husky
+ pnpm install
+
-1. Build the Docker image (replace `start-ui-web` with your project name)
-```
-docker build -t start-ui-web .
-```
-
-2. Run the Docker image (replace `start-ui-web` with your project name)
-```
-docker run -p 80:3000 start-ui-web
-```
-Application will be exposed on port 80 ([http://localhost](http://localhost))
+From now husky should have been removed; and lefthook should run your hooks correctly.
+
+
diff --git a/app.config.ts b/app.config.ts
new file mode 100644
index 000000000..be51fd803
--- /dev/null
+++ b/app.config.ts
@@ -0,0 +1,22 @@
+import { defineConfig } from '@tanstack/react-start/config';
+import tsConfigPaths from 'vite-tsconfig-paths';
+
+export default defineConfig({
+ vite: {
+ plugins: [
+ tsConfigPaths({
+ projects: ['./tsconfig.json'],
+ }),
+ ],
+ },
+ server: {
+ routeRules: {
+ '/storybook': {
+ redirect: {
+ to: '/storybook/index.html',
+ statusCode: 301,
+ },
+ },
+ },
+ },
+});
diff --git a/app/api.ts b/app/api.ts
new file mode 100644
index 000000000..87f9be0ce
--- /dev/null
+++ b/app/api.ts
@@ -0,0 +1,6 @@
+import {
+ createStartAPIHandler,
+ defaultAPIFileRouteHandler,
+} from '@tanstack/react-start/api';
+
+export default createStartAPIHandler(defaultAPIFileRouteHandler);
diff --git a/app/client.tsx b/app/client.tsx
new file mode 100644
index 000000000..fa68a3e81
--- /dev/null
+++ b/app/client.tsx
@@ -0,0 +1,9 @@
+///
+import { StartClient } from '@tanstack/react-start';
+import { hydrateRoot } from 'react-dom/client';
+
+import { createRouter } from './router';
+
+const router = createRouter();
+
+hydrateRoot(document, );
diff --git a/app/components/back-button.tsx b/app/components/back-button.tsx
new file mode 100644
index 000000000..35ebe24b9
--- /dev/null
+++ b/app/components/back-button.tsx
@@ -0,0 +1,40 @@
+import { Link, useCanGoBack, useRouter } from '@tanstack/react-router';
+import { ArrowLeftIcon } from 'lucide-react';
+import { ComponentProps } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { Button } from '@/components/ui/button';
+
+export const BackButton = ({
+ children,
+ linkProps,
+ ...props
+}: ComponentProps & {
+ linkProps?: Partial, 'children'>>;
+}) => {
+ const { t } = useTranslation(['components']);
+ const canGoBack = useCanGoBack();
+ const router = useRouter();
+
+ return (
+
+ {
+ if (canGoBack) {
+ e.preventDefault();
+ router.history.back();
+ }
+ }}
+ {...linkProps}
+ >
+ {children ?? (
+ <>
+
+ {t('components:backButton.label')}
+ >
+ )}
+
+
+ );
+};
diff --git a/app/components/brand/logo.stories.tsx b/app/components/brand/logo.stories.tsx
new file mode 100644
index 000000000..12733c6ad
--- /dev/null
+++ b/app/components/brand/logo.stories.tsx
@@ -0,0 +1,15 @@
+import { Meta } from '@storybook/react-vite';
+
+import { Logo } from '@/components/brand/logo';
+
+export default {
+ title: 'Brand/Logo',
+} satisfies Meta;
+
+export const Default = () => {
+ return ;
+};
+
+export const Color = () => {
+ return ;
+};
diff --git a/app/components/brand/logo.tsx b/app/components/brand/logo.tsx
new file mode 100644
index 000000000..8e3674684
--- /dev/null
+++ b/app/components/brand/logo.tsx
@@ -0,0 +1,24 @@
+import type { SVGProps } from 'react';
+
+import { cn } from '@/lib/tailwind/utils';
+
+export const Logo = (props: SVGProps) => (
+
+ Start UI
+
+
+
+);
diff --git a/app/components/form/docs.stories.tsx b/app/components/form/docs.stories.tsx
new file mode 100644
index 000000000..18f8529c8
--- /dev/null
+++ b/app/components/form/docs.stories.tsx
@@ -0,0 +1,116 @@
+import { zodResolver } from '@hookform/resolvers/zod';
+import { Meta } from '@storybook/react-vite';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+import { zu } from '@/lib/zod/zod-utils';
+
+import {
+ Form,
+ FormField,
+ FormFieldController,
+ FormFieldError,
+ FormFieldHelper,
+ FormFieldLabel,
+} from '@/components/form';
+import { onSubmit } from '@/components/form/docs.utils';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+
+export default {
+ title: 'Form/Form',
+} satisfies Meta;
+
+const zFormSchema = () =>
+ z.object({
+ name: zu.string.nonEmpty(z.string(), {
+ required_error: 'Name is required',
+ }),
+ other: zu.string.nonEmptyNullish(z.string()),
+ });
+
+export const Default = () => {
+ const form = useForm({
+ mode: 'onBlur',
+ resolver: zodResolver(zFormSchema()),
+ defaultValues: {
+ name: '',
+ other: '',
+ },
+ });
+
+ return (
+
+ );
+};
+
+export const NoHtmlForm = () => {
+ const form = useForm({
+ mode: 'onBlur',
+ resolver: zodResolver(zFormSchema()),
+ defaultValues: {
+ name: '',
+ other: '',
+ },
+ });
+
+ return (
+
+
+ );
+};
diff --git a/app/components/form/docs.utils.tsx b/app/components/form/docs.utils.tsx
new file mode 100644
index 000000000..6d9af6f3f
--- /dev/null
+++ b/app/components/form/docs.utils.tsx
@@ -0,0 +1,11 @@
+import { toast } from 'sonner';
+
+export const onSubmit = (values: ExplicitAny) => {
+ toast('You submitted the following values', {
+ description: (
+
+ {JSON.stringify(values, null, 2)}
+
+ ),
+ });
+};
diff --git a/app/components/form/field-date/docs.stories.tsx b/app/components/form/field-date/docs.stories.tsx
new file mode 100644
index 000000000..7ddda44f7
--- /dev/null
+++ b/app/components/form/field-date/docs.stories.tsx
@@ -0,0 +1,77 @@
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+import { FormFieldController } from '@/components/form';
+import { onSubmit } from '@/components/form/docs.utils';
+import { Button } from '@/components/ui/button';
+
+import { Form, FormField, FormFieldHelper, FormFieldLabel } from '..';
+
+export default {
+ title: 'Form/FieldDate',
+};
+
+const zFormSchema = () =>
+ z.object({
+ date: z.date(),
+ });
+
+const formOptions = {
+ mode: 'onBlur',
+ resolver: zodResolver(zFormSchema()),
+ defaultValues: {
+ date: null as unknown as Date,
+ },
+} as const;
+
+export const Default = () => {
+ const form = useForm(formOptions);
+
+ return (
+
+ );
+};
+
+export const CalendarCustomization = () => {
+ const form = useForm(formOptions);
+
+ return (
+
+ );
+};
diff --git a/app/components/form/field-date/index.tsx b/app/components/form/field-date/index.tsx
new file mode 100644
index 000000000..2f988a01c
--- /dev/null
+++ b/app/components/form/field-date/index.tsx
@@ -0,0 +1,81 @@
+import { ComponentProps } from 'react';
+import { Controller, FieldPath, FieldValues } from 'react-hook-form';
+
+import { cn } from '@/lib/tailwind/utils';
+
+import { useFormField } from '@/components/form/form-field';
+import { FieldProps } from '@/components/form/form-field-controller';
+import { FormFieldError } from '@/components/form/form-field-error';
+import { DatePicker } from '@/components/ui/date-picker';
+
+export type FieldDateProps<
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+> = FieldProps<
+ TFieldValues,
+ TName,
+ {
+ type: 'date';
+ containerProps?: ComponentProps<'div'>;
+ } & ComponentProps
+>;
+
+export const FieldDate = <
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+>(
+ props: FieldDateProps
+) => {
+ const {
+ name,
+ control,
+ disabled,
+ defaultValue,
+ shouldUnregister,
+ type,
+ containerProps,
+ ...rest
+ } = props;
+
+ const ctx = useFormField();
+
+ return (
+ (
+
+ {
+ field.onChange(e);
+ rest.onChange?.(e);
+ }}
+ onBlur={(e) => {
+ field.onBlur();
+ rest.onBlur?.(e);
+ }}
+ />
+
+
+ )}
+ />
+ );
+};
diff --git a/app/components/form/field-number/docs.stories.tsx b/app/components/form/field-number/docs.stories.tsx
new file mode 100644
index 000000000..503e04bf9
--- /dev/null
+++ b/app/components/form/field-number/docs.stories.tsx
@@ -0,0 +1,161 @@
+import { zodResolver } from '@hookform/resolvers/zod';
+import { Meta } from '@storybook/react-vite';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+import {
+ Form,
+ FormField,
+ FormFieldController,
+ FormFieldHelper,
+ FormFieldLabel,
+} from '@/components/form';
+import { onSubmit } from '@/components/form/docs.utils';
+import { FieldNumber } from '@/components/form/field-number';
+import { Button } from '@/components/ui/button';
+
+export default {
+ title: 'Form/FieldNumber',
+} satisfies Meta;
+
+const zFormSchema = () =>
+ z.object({
+ balance: z.number().min(0),
+ });
+
+const formOptions = {
+ mode: 'onBlur',
+ resolver: zodResolver(zFormSchema()),
+} as const;
+
+export const Default = () => {
+ const form = useForm(formOptions);
+
+ return (
+
+ );
+};
+
+export const DefaultValue = () => {
+ const form = useForm({
+ ...formOptions,
+ defaultValues: {
+ balance: 30,
+ },
+ });
+
+ return (
+
+ );
+};
+
+export const Currency = () => {
+ const form = useForm(formOptions);
+
+ return (
+
+ );
+};
+
+export const Disabled = () => {
+ const form = useForm({ ...formOptions, defaultValues: { balance: 42 } });
+
+ return (
+
+ );
+};
+
+export const ReadOnly = () => {
+ const form = useForm({ ...formOptions, defaultValues: { balance: 42 } });
+
+ return (
+
+ );
+};
diff --git a/app/components/form/field-number/index.tsx b/app/components/form/field-number/index.tsx
new file mode 100644
index 000000000..bcc74ee5f
--- /dev/null
+++ b/app/components/form/field-number/index.tsx
@@ -0,0 +1,100 @@
+import { ComponentProps } from 'react';
+import { Controller, FieldPath, FieldValues } from 'react-hook-form';
+import { isNullish } from 'remeda';
+
+import { cn } from '@/lib/tailwind/utils';
+
+import { NumberInput } from '@/components/ui/number-input';
+
+import { useFormField } from '../form-field';
+import { FieldProps } from '../form-field-controller';
+import { FormFieldError } from '../form-field-error';
+
+export type FieldNumberProps<
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+> = FieldProps<
+ TFieldValues,
+ TName,
+ {
+ type: 'number';
+ containerProps?: ComponentProps<'div'>;
+ inCents?: boolean;
+ } & ComponentProps
+>;
+
+export const FieldNumber = <
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+>(
+ props: FieldNumberProps
+) => {
+ const {
+ name,
+ type,
+ disabled,
+ defaultValue,
+ shouldUnregister,
+ control,
+ containerProps,
+ inCents,
+ ...rest
+ } = props;
+
+ const ctx = useFormField();
+
+ const formatValue = (
+ value: number | undefined | null,
+ type: 'to-cents' | 'from-cents'
+ ) => {
+ if (isNullish(value)) return null;
+ if (inCents !== true) return value ?? null;
+ if (type === 'to-cents') return Math.round(value * 100);
+ if (type === 'from-cents') return value / 100;
+ return null;
+ };
+
+ return (
+ {
+ const { onChange, value, ...fieldProps } = field;
+ return (
+
+ {
+ onChange(formatValue(value, 'to-cents'));
+ rest.onValueChange?.(value);
+ }}
+ onBlur={(e) => {
+ field.onBlur();
+ rest.onBlur?.(e);
+ }}
+ />
+
+
+ );
+ }}
+ />
+ );
+};
diff --git a/app/components/form/field-otp/docs.stories.tsx b/app/components/form/field-otp/docs.stories.tsx
new file mode 100644
index 000000000..3db6fd147
--- /dev/null
+++ b/app/components/form/field-otp/docs.stories.tsx
@@ -0,0 +1,159 @@
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+import { zu } from '@/lib/zod/zod-utils';
+
+import { onSubmit } from '@/components/form/docs.utils';
+import { Button } from '@/components/ui/button';
+
+import { Form, FormField, FormFieldController, FormFieldLabel } from '../';
+
+export default {
+ title: 'Form/FieldOtp',
+};
+
+const zFormSchema = (options: { length?: number } = {}) => {
+ const length = options.length ?? 6;
+ return z.object({
+ code: zu.string.nonEmpty(
+ z
+ .string()
+ .min(length, `Code is ${length} digits`)
+ .max(length, `Code is ${length} digits`),
+ {
+ required_error: 'Code is required',
+ }
+ ),
+ });
+};
+
+const formOptions = {
+ mode: 'onBlur',
+ resolver: zodResolver(zFormSchema()),
+} as const;
+
+export const Default = () => {
+ const form = useForm(formOptions);
+
+ return (
+
+ );
+};
+
+export const DefaultValue = () => {
+ const form = useForm({
+ ...formOptions,
+ defaultValues: {
+ code: '927342',
+ },
+ });
+
+ return (
+
+ );
+};
+
+export const Disabled = () => {
+ const form = useForm(formOptions);
+
+ return (
+
+ );
+};
+
+export const CustomLength = () => {
+ const form = useForm({
+ ...formOptions,
+ resolver: zodResolver(zFormSchema({ length: 4 })),
+ });
+
+ return (
+
+ );
+};
+
+export const AutoSubmit = () => {
+ const form = useForm(formOptions);
+
+ return (
+
+ );
+};
diff --git a/src/components/Form/FieldOtp/FieldOtp.spec.tsx b/app/components/form/field-otp/field-otp.spec.tsx
similarity index 85%
rename from src/components/Form/FieldOtp/FieldOtp.spec.tsx
rename to app/components/form/field-otp/field-otp.spec.tsx
index 5e42a2d97..02a037b60 100644
--- a/src/components/Form/FieldOtp/FieldOtp.spec.tsx
+++ b/app/components/form/field-otp/field-otp.spec.tsx
@@ -1,10 +1,10 @@
import { expect, test, vi } from 'vitest';
import { z } from 'zod';
-import { FormMocked } from '@/components/Form/form-test-utils';
import { render, screen, setupUser } from '@/tests/utils';
import { FormField, FormFieldController, FormFieldLabel } from '..';
+import { FormMocked } from '../form-test-utils';
test('update value', async () => {
const user = setupUser();
@@ -19,7 +19,12 @@ test('update value', async () => {
{({ form }) => (
Code
-
+
)}
@@ -47,7 +52,12 @@ test('default value', async () => {
{({ form }) => (
Code
-
+
)}
@@ -73,6 +83,7 @@ test('auto submit', async () => {
type="otp"
control={form.control}
name="code"
+ maxLength={6}
autoSubmit
/>
@@ -102,7 +113,8 @@ test('disabled', async () => {
type="otp"
control={form.control}
name="code"
- isDisabled
+ maxLength={6}
+ disabled
/>
)}
@@ -112,5 +124,5 @@ test('disabled', async () => {
await user.click(input);
await user.paste('123456');
await user.click(screen.getByRole('button', { name: 'Submit' }));
- expect(mockedSubmit).toHaveBeenCalledWith({ code: '000000' });
+ expect(mockedSubmit).toHaveBeenCalledWith({ code: undefined });
});
diff --git a/app/components/form/field-otp/index.tsx b/app/components/form/field-otp/index.tsx
new file mode 100644
index 000000000..1f076e12b
--- /dev/null
+++ b/app/components/form/field-otp/index.tsx
@@ -0,0 +1,108 @@
+import { ComponentProps, ComponentRef, useRef } from 'react';
+import { Controller, FieldPath, FieldValues } from 'react-hook-form';
+
+import { cn } from '@/lib/tailwind/utils';
+
+import {
+ InputOTP,
+ InputOTPGroup,
+ InputOTPSlot,
+} from '@/components/ui/input-otp';
+
+import { useFormField } from '../form-field';
+import { FieldProps } from '../form-field-controller';
+import { FormFieldError } from '../form-field-error';
+
+export type FieldOtpProps<
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+> = FieldProps<
+ TFieldValues,
+ TName,
+ {
+ type: 'otp';
+ autoSubmit?: boolean;
+ containerProps?: ComponentProps<'div'>;
+ } & Omit, 'children'>
+>;
+
+export const FieldOtp = <
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+>(
+ props: FieldOtpProps
+) => {
+ const {
+ name,
+ type,
+ disabled,
+ defaultValue,
+ shouldUnregister,
+ control,
+ containerProps,
+ autoSubmit,
+ ...rest
+ } = props;
+
+ const containerRef = useRef>(null);
+ const ctx = useFormField();
+ return (
+ (
+
+ {
+ rest.onComplete?.(v);
+ // Only auto submit on first try
+ if (!formState.isSubmitted && autoSubmit) {
+ const button = document.createElement('button');
+ button.type = 'submit';
+ button.style.display = 'none';
+ containerRef.current?.append(button);
+ button.click();
+ button.remove();
+ }
+ }}
+ {...rest}
+ {...field}
+ onChange={(e) => {
+ field.onChange(e);
+ rest.onChange?.(e);
+ }}
+ onBlur={(e) => {
+ field.onBlur();
+ rest.onBlur?.(e);
+ }}
+ >
+
+ {Array.from({ length: rest.maxLength }).map((_, index) => (
+ // eslint-disable-next-line @eslint-react/no-array-index-key
+
+ ))}
+
+
+
+
+ )}
+ />
+ );
+};
diff --git a/app/components/form/field-select/docs.stories.tsx b/app/components/form/field-select/docs.stories.tsx
new file mode 100644
index 000000000..2d1898283
--- /dev/null
+++ b/app/components/form/field-select/docs.stories.tsx
@@ -0,0 +1,171 @@
+import { zodResolver } from '@hookform/resolvers/zod';
+import { Meta } from '@storybook/react-vite';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+import {
+ Form,
+ FormField,
+ FormFieldController,
+ FormFieldLabel,
+} from '@/components/form';
+import { onSubmit } from '@/components/form/docs.utils';
+import { Button } from '@/components/ui/button';
+import { Select } from '@/components/ui/select';
+
+export default {
+ title: 'Form/FieldSelect',
+} satisfies Meta;
+
+const zFormSchema = () =>
+ z.object({
+ bear: z.enum([
+ 'bearstrong',
+ 'pawdrin',
+ 'grizzlyrin',
+ 'jemibear',
+ 'ridepaw',
+ 'michaelpawanderson',
+ ]),
+ });
+
+const options = [
+ {
+ id: 'bearstrong',
+ label: 'Bearstrong',
+ },
+ {
+ id: 'pawdrin',
+ label: 'Buzz Pawdrin',
+ },
+ {
+ id: 'grizzlyrin',
+ label: 'Yuri Grizzlyrin',
+ },
+ {
+ id: 'jemibear',
+ label: 'Mae Jemibear',
+ disabled: true,
+ },
+ {
+ id: 'ridepaw',
+ label: 'Sally Ridepaw',
+ },
+ {
+ id: 'michaelpawanderson',
+ label: 'Michael Paw Anderson',
+ },
+] as const;
+
+const formOptions = {
+ mode: 'onBlur',
+ resolver: zodResolver(zFormSchema()),
+} as const;
+
+export const Default = () => {
+ const form = useForm(formOptions);
+
+ return (
+
+ );
+};
+
+export const DefaultValue = () => {
+ const form = useForm>>({
+ ...formOptions,
+ defaultValues: {
+ bear: 'pawdrin',
+ },
+ });
+
+ return (
+
+ );
+};
+
+export const Disabled = () => {
+ const form = useForm>>({
+ ...formOptions,
+ defaultValues: {
+ bear: 'michaelpawanderson',
+ },
+ });
+
+ return (
+
+ );
+};
+
+export const ReadOnly = () => {
+ const form = useForm>>({
+ ...formOptions,
+ defaultValues: {
+ bear: 'michaelpawanderson',
+ },
+ });
+
+ return (
+
+ );
+};
diff --git a/app/components/form/field-select/index.tsx b/app/components/form/field-select/index.tsx
new file mode 100644
index 000000000..9e89556ee
--- /dev/null
+++ b/app/components/form/field-select/index.tsx
@@ -0,0 +1,92 @@
+import { ComponentProps } from 'react';
+import { Controller, FieldPath, FieldValues } from 'react-hook-form';
+
+import { cn } from '@/lib/tailwind/utils';
+
+import { FormFieldError } from '@/components/form';
+import { useFormField } from '@/components/form/form-field';
+import { FieldProps } from '@/components/form/form-field-controller';
+import { Select } from '@/components/ui/select';
+
+export type FieldSelectProps<
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+> = FieldProps<
+ TFieldValues,
+ TName,
+ {
+ type: 'select';
+ containerProps?: ComponentProps<'div'>;
+ } & ComponentProps
+>;
+
+export const FieldSelect = <
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+>(
+ props: FieldSelectProps
+) => {
+ const {
+ name,
+ control,
+ disabled,
+ defaultValue,
+ shouldUnregister,
+ type,
+ containerProps,
+ options,
+ ...rest
+ } = props;
+
+ const ctx = useFormField();
+
+ return (
+ {
+ return (
+
+ option.id === field.value) ?? null
+ }
+ onChange={(e) => {
+ field.onChange(e ? e.id : null);
+ rest.onChange?.(e);
+ }}
+ inputProps={{
+ id: ctx.id,
+ onBlur: (e) => {
+ field.onBlur();
+ rest.inputProps?.onBlur?.(e);
+ },
+ ...rest.inputProps,
+ }}
+ />
+
+
+ );
+ }}
+ />
+ );
+};
diff --git a/src/components/Form/FieldText/docs.stories.tsx b/app/components/form/field-text/docs.stories.tsx
similarity index 57%
rename from src/components/Form/FieldText/docs.stories.tsx
rename to app/components/form/field-text/docs.stories.tsx
index a5c5c4675..dc0a762a5 100644
--- a/src/components/Form/FieldText/docs.stories.tsx
+++ b/app/components/form/field-text/docs.stories.tsx
@@ -1,20 +1,20 @@
-import { Box, Button, Stack } from '@chakra-ui/react';
import { zodResolver } from '@hookform/resolvers/zod';
+import { ActivityIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
-import { LuActivity } from 'react-icons/lu';
import { z } from 'zod';
-import { FormFieldController } from '@/components/Form/FormFieldController';
-import { Icon } from '@/components/Icons';
import { zu } from '@/lib/zod/zod-utils';
+import { FormFieldController } from '@/components/form';
+import { onSubmit } from '@/components/form/docs.utils';
+import { Button } from '@/components/ui/button';
+
import { Form, FormField, FormFieldHelper, FormFieldLabel } from '../';
export default {
title: 'Form/FieldText',
};
-type FormSchema = z.infer>;
const zFormSchema = () =>
z.object({
name: zu.string.nonEmpty(z.string(), {
@@ -31,11 +31,11 @@ const formOptions = {
} as const;
export const Default = () => {
- const form = useForm(formOptions);
+ const form = useForm(formOptions);
return (
-