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 @@

Start UI Web

-[![Discord](https://img.shields.io/discord/452798408491663361)](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
Technologies logos of the starter
-[๐ŸŸฆ 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 ( + + ); +}; 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 ( +
+
+ + Name + + This is an helper text + + + Other (Custom) + ( + <> + + + + )} + /> + +
+ +
+
+
+ ); +}; + +export const NoHtmlForm = () => { + const form = useForm({ + mode: 'onBlur', + resolver: zodResolver(zFormSchema()), + defaultValues: { + name: '', + other: '', + }, + }); + + return ( +
+ +
+ + Name + + This is an helper text + + + Other (Custom) + ( + <> + + + + )} + /> + +
+ +
+
+
+ + ); +}; 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 ( +
+
+ + Date + + Help + +
+ +
+
+
+ ); +}; + +export const CalendarCustomization = () => { + const form = useForm(formOptions); + + return ( +
+
+ + Date + + Help + +
+ +
+
+
+ ); +}; 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 ( +
+
+ + Balance + + Help + +
+ +
+
+
+ ); +}; + +export const DefaultValue = () => { + const form = useForm({ + ...formOptions, + defaultValues: { + balance: 30, + }, + }); + + return ( +
+
+ + Balance + + Help + +
+ +
+
+
+ ); +}; + +export const Currency = () => { + const form = useForm(formOptions); + + return ( +
+
+ + Balance + + Help + +
+ +
+
+
+ ); +}; + +export const Disabled = () => { + const form = useForm({ ...formOptions, defaultValues: { balance: 42 } }); + + return ( +
+
+ + Balance + + Help + +
+ +
+
+
+ ); +}; + +export const ReadOnly = () => { + const form = useForm({ ...formOptions, defaultValues: { balance: 42 } }); + + return ( +
+
+ + Balance + + Help + +
+ +
+
+
+ ); +}; 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 ( +
+
+ + Code + + +
+ +
+
+
+ ); +}; + +export const DefaultValue = () => { + const form = useForm({ + ...formOptions, + defaultValues: { + code: '927342', + }, + }); + + return ( +
+
+ + Code + + +
+ +
+
+
+ ); +}; + +export const Disabled = () => { + const form = useForm(formOptions); + + return ( +
+
+ + Code + + +
+ +
+
+
+ ); +}; + +export const CustomLength = () => { + const form = useForm({ + ...formOptions, + resolver: zodResolver(zFormSchema({ length: 4 })), + }); + + return ( +
+
+ + Code + + +
+ +
+
+
+ ); +}; + +export const AutoSubmit = () => { + const form = useForm(formOptions); + + return ( +
+
+ + Code + + +
+ +
+
+
+ ); +}; 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 ( +
+
+ + Bearstronaut + + + +
+
+ ); +}; + +export const DefaultValue = () => { + const form = useForm>>({ + ...formOptions, + defaultValues: { + bear: 'pawdrin', + }, + }); + + return ( +
+
+ + Bearstronaut + + + + +
+
+ ); +}; + +export const Disabled = () => { + const form = useForm>>({ + ...formOptions, + defaultValues: { + bear: 'michaelpawanderson', + }, + }); + + return ( +
+
+ + Bearstronaut + + + + +
+
+ ); +}; + +export const ReadOnly = () => { + const form = useForm>>({ + ...formOptions, + defaultValues: { + bear: 'michaelpawanderson', + }, + }); + + return ( +
+
+ + Bearstronaut + + + + +
+
+ ); +}; 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 ( +
+ { + field.onChange(e); + rest.onChange?.(e); + }} + onBlur={(e) => { + field.onBlur(); + rest.onBlur?.(e); + }} + /> + +
+ )} + /> + ); +}; diff --git a/src/components/Form/FormFieldController.tsx b/app/components/form/form-field-controller.tsx similarity index 56% rename from src/components/Form/FormFieldController.tsx rename to app/components/form/form-field-controller.tsx index 03d15cedb..4496bc0b7 100644 --- a/src/components/Form/FormFieldController.tsx +++ b/app/components/form/form-field-controller.tsx @@ -1,5 +1,4 @@ import { createContext, useMemo } from 'react'; - import { Controller, ControllerProps, @@ -7,39 +6,36 @@ import { FieldValues, } from 'react-hook-form'; -import { useFormField } from '@/components/Form/FormField'; +import { FieldNumber, FieldNumberProps } from '@/components/form/field-number'; -import { FieldCheckbox, FieldCheckboxProps } from './FieldCheckbox'; -import { FieldCheckboxes, FieldCheckboxesProps } from './FieldCheckboxes'; -import { FieldDate, FieldDateProps } from './FieldDate'; -import { FieldMultiSelect, FieldMultiSelectProps } from './FieldMultiSelect'; -import { FieldNumber, FieldNumberProps } from './FieldNumber'; -import { FieldOtp, FieldOtpProps } from './FieldOtp'; -import { FieldPassword, FieldPasswordProps } from './FieldPassword'; -import { FieldRadios, FieldRadiosProps } from './FieldRadios'; -import { FieldSelect, FieldSelectProps } from './FieldSelect'; -import { FieldSwitch, FieldSwitchProps } from './FieldSwitch'; -import { FieldText, FieldTextProps } from './FieldText'; -import { FieldTextarea, FieldTextareaProps } from './FieldTextarea'; +import { FieldDate, FieldDateProps } from './field-date'; +import { FieldOtp, FieldOtpProps } from './field-otp'; +import { FieldSelect, FieldSelectProps } from './field-select'; +import { FieldText, FieldTextProps } from './field-text'; +import { useFormField } from './form-field'; -type FormFieldSize = 'sm' | 'md' | 'lg'; +type FormFieldSize = 'sm' | 'default' | 'lg'; type FieldCustomProps< TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath, > = { type: 'custom'; -} & Omit, 'disabled'> & +} & Pick< + ControllerProps, + 'defaultValue' | 'name' | 'shouldUnregister' | 'disabled' | 'render' +> & Required, 'control'>>; -export type FieldCommonProps< +type CustomProps = object; +export type FieldProps< TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath, + TProps extends CustomProps = CustomProps, > = Omit, 'render' | 'type'> & { size?: FormFieldSize; displayError?: boolean; - isDisabled?: boolean; -}; +} & Omit; export type FormFieldControllerProps< TFieldValues extends FieldValues = FieldValues, @@ -47,18 +43,11 @@ export type FormFieldControllerProps< > = | FieldCustomProps // -- ADD NEW FIELD PROPS TYPE HERE -- - | FieldCheckboxProps - | FieldSwitchProps - | FieldTextProps - | FieldTextareaProps + | FieldNumberProps | FieldSelectProps - | FieldMultiSelectProps - | FieldOtpProps | FieldDateProps - | FieldNumberProps - | FieldPasswordProps - | FieldCheckboxesProps - | FieldRadiosProps; + | FieldTextProps + | FieldOtpProps; export const FormFieldController = < TFieldValues extends FieldValues = FieldValues, @@ -70,7 +59,7 @@ export const FormFieldController = < const props = { ..._props, - size: 'size' in _props ? _props.size ?? size : size, + size: 'size' in _props ? (_props.size ?? size) : size, }; const getField = () => { @@ -83,39 +72,17 @@ export const FormFieldController = < case 'tel': return ; - case 'password': - return ; - - case 'number': - return ; - - case 'textarea': - return ; - case 'otp': return ; - case 'select': - return ; - - case 'multi-select': - return ; - case 'date': return ; - case 'checkboxes': - return ; - - case 'radios': - return ; - - case 'checkbox': - return ; - - case 'switch': - return ; + case 'select': + return ; + case 'number': + return ; // -- ADD NEW FIELD COMPONENT HERE -- } }; @@ -133,9 +100,9 @@ export const FormFieldController = < ); return ( - + {getField()} - + ); }; diff --git a/src/components/Form/FormFieldError.tsx b/app/components/form/form-field-error.tsx similarity index 55% rename from src/components/Form/FormFieldError.tsx rename to app/components/form/form-field-error.tsx index f603bb94e..d309eb569 100644 --- a/src/components/Form/FormFieldError.tsx +++ b/app/components/form/form-field-error.tsx @@ -1,6 +1,5 @@ -import { ElementRef, ReactNode, useContext } from 'react'; - -import { Flex, FlexProps, SlideFade } from '@chakra-ui/react'; +import { AlertCircleIcon } from 'lucide-react'; +import { ComponentProps, ReactNode, use } from 'react'; import { ControllerProps, FieldError, @@ -10,19 +9,20 @@ import { get, useFormContext, } from 'react-hook-form'; -import { LuAlertCircle } from 'react-icons/lu'; + +import { cn } from '@/lib/tailwind/utils'; + +import { useFormField } from '@/components/form/form-field'; import { FormFieldControllerContext, FormFieldControllerContextValue, -} from '@/components/Form/FormFieldController'; -import { Icon } from '@/components/Icons'; -import { fixedForwardRef } from '@/lib/utils'; +} from './form-field-controller'; type FormFieldErrorProps< TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath, -> = Omit & { +> = Omit, 'children'> & { children?: (params: { error?: FieldError }) => ReactNode; } & ( | Required, 'control' | 'name'>> @@ -30,22 +30,24 @@ type FormFieldErrorProps< | {} ); -const FormFieldErrorComponent = < +export const FormFieldError = < TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath, ->( - { children, ...props }: FormFieldErrorProps, - ref: ElementRef -) => { - const ctx = useContext({ + className, + children, + ...props +}: FormFieldErrorProps) => { + const fieldCtx = useFormField(); + const controllerCtx = use | null>(FormFieldControllerContext as ExplicitAny); const { formState: { errors }, } = useFormContext(); - const control = 'control' in props ? props.control : ctx?.control; - const name = 'name' in props ? props.name : ctx?.name; + const control = 'control' in props ? props.control : controllerCtx?.control; + const name = 'name' in props ? props.name : controllerCtx?.name; if (!control || !name) { throw new Error( @@ -60,7 +62,7 @@ const FormFieldErrorComponent = < return null; } - if (ctx?.displayError === false) { + if (controllerCtx?.displayError === false) { return null; } @@ -69,9 +71,8 @@ const FormFieldErrorComponent = < } const { - // eslint-disable-next-line @typescript-eslint/no-unused-vars control: _, - // eslint-disable-next-line @typescript-eslint/no-unused-vars + name: __, ...rest } = 'control' in props @@ -79,14 +80,16 @@ const FormFieldErrorComponent = < : { ...props, control: undefined, name: undefined }; return ( - - - - {errorMessage} - - +
+ + {errorMessage} +
); }; - -FormFieldErrorComponent.displayName = 'FormFieldError'; -export const FormFieldError = fixedForwardRef(FormFieldErrorComponent); diff --git a/app/components/form/form-field-helper.tsx b/app/components/form/form-field-helper.tsx new file mode 100644 index 000000000..ae4fb7b9e --- /dev/null +++ b/app/components/form/form-field-helper.tsx @@ -0,0 +1,15 @@ +import { ComponentProps } from 'react'; + +import { cn } from '@/lib/tailwind/utils'; + +export const FormFieldHelper = ({ + className, + ...props +}: ComponentProps<'div'>) => { + return ( +
+ ); +}; diff --git a/app/components/form/form-field-label.tsx b/app/components/form/form-field-label.tsx new file mode 100644 index 000000000..cf404d8c3 --- /dev/null +++ b/app/components/form/form-field-label.tsx @@ -0,0 +1,21 @@ +import { ComponentProps } from 'react'; + +import { cn } from '@/lib/tailwind/utils'; + +import { useFormField } from './form-field'; + +type FormFieldLabelProps = ComponentProps<'label'>; + +export const FormFieldLabel = ({ + className, + ...props +}: FormFieldLabelProps) => { + const ctx = useFormField(); + return ( +