create-nexst-monorepo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (303) hide show
  1. package/dist/index.js +1727 -0
  2. package/package.json +37 -0
  3. package/template/.agents/plugins/marketplace.json +21 -0
  4. package/template/.claude/settings.json +6 -0
  5. package/template/.claude/skills/frontend-design/LICENSE.txt +177 -0
  6. package/template/.claude/skills/frontend-design/SKILL.md +154 -0
  7. package/template/.claude/skills/theme-factory/SKILL.md +95 -0
  8. package/template/.claude/skills/theme-factory/references/schema.md +104 -0
  9. package/template/.claude/skills/theme-factory/scripts/theme.mjs +261 -0
  10. package/template/.dockerignore +43 -0
  11. package/template/.editorconfig +10 -0
  12. package/template/.github/workflows/ci.yaml +289 -0
  13. package/template/.github/workflows/publish-app.yaml +66 -0
  14. package/template/.github/workflows/publish.yaml +148 -0
  15. package/template/.github/workflows/snyk.yaml +34 -0
  16. package/template/.vscode/settings.json +8 -0
  17. package/template/AGENTS.md +42 -0
  18. package/template/Dockerfile.base +9 -0
  19. package/template/README.md +54 -0
  20. package/template/_gitignore +10 -0
  21. package/template/apps/api/.env.example +32 -0
  22. package/template/apps/api/AGENTS.md +44 -0
  23. package/template/apps/api/Dockerfile +69 -0
  24. package/template/apps/api/_gitignore +8 -0
  25. package/template/apps/api/knip.config.ts +22 -0
  26. package/template/apps/api/kustomize/base/application.properties +16 -0
  27. package/template/apps/api/kustomize/base/deployment.yaml +94 -0
  28. package/template/apps/api/kustomize/base/kustomization.yaml +17 -0
  29. package/template/apps/api/kustomize/base/service.yaml +10 -0
  30. package/template/apps/api/kustomize/overlays/prod/kustomization.yaml +17 -0
  31. package/template/apps/api/kustomize/overlays/staging/application.properties +1 -0
  32. package/template/apps/api/kustomize/overlays/staging/kustomization.yaml +21 -0
  33. package/template/apps/api/kustomize/secret.keys +14 -0
  34. package/template/apps/api/oxfmt.config.ts +8 -0
  35. package/template/apps/api/oxlint.config.ts +107 -0
  36. package/template/apps/api/package.json +79 -0
  37. package/template/apps/api/prisma/migrations/20260927000000_init/migration.sql +113 -0
  38. package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
  39. package/template/apps/api/prisma/schema.prisma +113 -0
  40. package/template/apps/api/prisma.config.ts +7 -0
  41. package/template/apps/api/scripts/auth.cli.ts +19 -0
  42. package/template/apps/api/scripts/seal-secret.sh +171 -0
  43. package/template/apps/api/sonar-project.properties +9 -0
  44. package/template/apps/api/src/app-info/app-info.dto.ts +10 -0
  45. package/template/apps/api/src/app-info/app-info.module.ts +7 -0
  46. package/template/apps/api/src/app-info/app-info.resolver.spec.ts +14 -0
  47. package/template/apps/api/src/app-info/app-info.resolver.ts +21 -0
  48. package/template/apps/api/src/app.module.spec.ts +93 -0
  49. package/template/apps/api/src/app.module.ts +77 -0
  50. package/template/apps/api/src/auth/auth.guard.spec.ts +90 -0
  51. package/template/apps/api/src/auth/auth.guard.ts +97 -0
  52. package/template/apps/api/src/auth/auth.ts +129 -0
  53. package/template/apps/api/src/auth/decorator/allow-anonymous.decorator.ts +17 -0
  54. package/template/apps/api/src/auth/decorator/is-email-verified.decorator.spec.ts +41 -0
  55. package/template/apps/api/src/auth/decorator/is-email-verified.decorator.ts +20 -0
  56. package/template/apps/api/src/auth/decorator/roles.decorator.ts +27 -0
  57. package/template/apps/api/src/auth/testing/make-session.ts +31 -0
  58. package/template/apps/api/src/auth/types/auth.types.ts +27 -0
  59. package/template/apps/api/src/bootstrap/register-graphql-enums.ts +12 -0
  60. package/template/apps/api/src/bootstrap/security.ts +24 -0
  61. package/template/apps/api/src/bootstrap/swagger.ts +28 -0
  62. package/template/apps/api/src/config/app.config.spec.ts +22 -0
  63. package/template/apps/api/src/config/app.config.ts +122 -0
  64. package/template/apps/api/src/config/graphql-depth-limit.ts +103 -0
  65. package/template/apps/api/src/config/graphql.config.ts +77 -0
  66. package/template/apps/api/src/config/resources/app.yaml +29 -0
  67. package/template/apps/api/src/errors/app-parse-uuid.pipe.ts +27 -0
  68. package/template/apps/api/src/errors/app.exception.ts +47 -0
  69. package/template/apps/api/src/errors/error-code.enum.ts +54 -0
  70. package/template/apps/api/src/errors/http-status-fallback-code.util.ts +27 -0
  71. package/template/apps/api/src/errors/validation-exception.factory.ts +43 -0
  72. package/template/apps/api/src/health/health.controller.spec.ts +82 -0
  73. package/template/apps/api/src/health/health.controller.ts +57 -0
  74. package/template/apps/api/src/health/health.module.ts +12 -0
  75. package/template/apps/api/src/logging/context-logger.ts +18 -0
  76. package/template/apps/api/src/logging/logger.config.ts +23 -0
  77. package/template/apps/api/src/logging/logging.module.ts +22 -0
  78. package/template/apps/api/src/mail/mail.module.ts +12 -0
  79. package/template/apps/api/src/mail/mail.service.spec.ts +47 -0
  80. package/template/apps/api/src/mail/mail.service.ts +123 -0
  81. package/template/apps/api/src/main.ts +41 -0
  82. package/template/apps/api/src/prisma/prisma.module.ts +12 -0
  83. package/template/apps/api/src/prisma/prisma.service.spec.ts +136 -0
  84. package/template/apps/api/src/prisma/prisma.service.ts +138 -0
  85. package/template/apps/api/src/queue/dto/enqueue-example.input.ts +11 -0
  86. package/template/apps/api/src/queue/example-queue.resolver.ts +14 -0
  87. package/template/apps/api/src/queue/example-queue.service.spec.ts +21 -0
  88. package/template/apps/api/src/queue/example-queue.service.ts +32 -0
  89. package/template/apps/api/src/queue/queue.module.ts +27 -0
  90. package/template/apps/api/src/r2/r2.module.ts +12 -0
  91. package/template/apps/api/src/r2/r2.service.spec.ts +117 -0
  92. package/template/apps/api/src/r2/r2.service.ts +131 -0
  93. package/template/apps/api/src/user/dto/user.dto.ts +26 -0
  94. package/template/apps/api/src/user/user.module.ts +10 -0
  95. package/template/apps/api/src/user/user.resolver.spec.ts +18 -0
  96. package/template/apps/api/src/user/user.resolver.ts +24 -0
  97. package/template/apps/api/src/utils/catch-everything-exception.filter.spec.ts +292 -0
  98. package/template/apps/api/src/utils/catch-everything-exception.filter.ts +70 -0
  99. package/template/apps/api/src/utils/error-handler.util.spec.ts +20 -0
  100. package/template/apps/api/src/utils/error-handler.util.ts +12 -0
  101. package/template/apps/api/src/utils/escape-like-pattern.util.spec.ts +29 -0
  102. package/template/apps/api/src/utils/escape-like-pattern.util.ts +11 -0
  103. package/template/apps/api/src/utils/pagination.input.ts +20 -0
  104. package/template/apps/api/src/utils/prisma-exception.filter.spec.ts +186 -0
  105. package/template/apps/api/src/utils/prisma-exception.filter.ts +45 -0
  106. package/template/apps/api/src/utils/prisma-graphql-exception.filter.spec.ts +216 -0
  107. package/template/apps/api/src/utils/prisma-graphql-exception.filter.ts +43 -0
  108. package/template/apps/api/tests/app.e2e-spec.ts +35 -0
  109. package/template/apps/api/tests/auth.e2e-spec.ts +38 -0
  110. package/template/apps/api/tests/helpers/api.ts +27 -0
  111. package/template/apps/api/tsconfig.json +11 -0
  112. package/template/apps/api/vitest.config.e2e.ts +16 -0
  113. package/template/apps/api/vitest.config.ts +40 -0
  114. package/template/apps/web/.env.example +7 -0
  115. package/template/apps/web/.storybook/decorators.tsx +38 -0
  116. package/template/apps/web/.storybook/main.ts +17 -0
  117. package/template/apps/web/.storybook/preview.ts +38 -0
  118. package/template/apps/web/.storybook/vitest.setup.ts +5 -0
  119. package/template/apps/web/AGENTS.md +29 -0
  120. package/template/apps/web/Dockerfile +49 -0
  121. package/template/apps/web/_gitignore +16 -0
  122. package/template/apps/web/knip.config.ts +15 -0
  123. package/template/apps/web/kustomize/base/application.properties +5 -0
  124. package/template/apps/web/kustomize/base/deployment.yaml +60 -0
  125. package/template/apps/web/kustomize/base/kustomization.yaml +17 -0
  126. package/template/apps/web/kustomize/base/service.yaml +10 -0
  127. package/template/apps/web/kustomize/overlays/prod/kustomization.yaml +12 -0
  128. package/template/apps/web/kustomize/overlays/staging/application.properties +2 -0
  129. package/template/apps/web/kustomize/overlays/staging/kustomization.yaml +18 -0
  130. package/template/apps/web/messages/de.json +124 -0
  131. package/template/apps/web/messages/en.json +124 -0
  132. package/template/apps/web/next-env.d.ts +7 -0
  133. package/template/apps/web/next.config.ts +39 -0
  134. package/template/apps/web/oxfmt.config.ts +9 -0
  135. package/template/apps/web/oxlint.config.ts +36 -0
  136. package/template/apps/web/package.json +80 -0
  137. package/template/apps/web/playwright.config.ts +34 -0
  138. package/template/apps/web/postcss.config.mjs +7 -0
  139. package/template/apps/web/sonar-project.properties +9 -0
  140. package/template/apps/web/src/app/[locale]/(auth)/recovery/page.tsx +22 -0
  141. package/template/apps/web/src/app/[locale]/(auth)/reset-password/page.tsx +29 -0
  142. package/template/apps/web/src/app/[locale]/(auth)/sign-in/page.tsx +24 -0
  143. package/template/apps/web/src/app/[locale]/(auth)/sign-up/page.tsx +24 -0
  144. package/template/apps/web/src/app/[locale]/(auth)/verify/page.tsx +24 -0
  145. package/template/apps/web/src/app/[locale]/dashboard/page.tsx +34 -0
  146. package/template/apps/web/src/app/[locale]/layout.tsx +60 -0
  147. package/template/apps/web/src/app/[locale]/page.tsx +23 -0
  148. package/template/apps/web/src/app/api/health/route.ts +3 -0
  149. package/template/apps/web/src/app/global-error.tsx +19 -0
  150. package/template/apps/web/src/app/globals.css +98 -0
  151. package/template/apps/web/src/app/icon.svg +1 -0
  152. package/template/apps/web/src/app/robots.ts +15 -0
  153. package/template/apps/web/src/app/sitemap.ts +24 -0
  154. package/template/apps/web/src/components/alert/alert.stories.tsx +25 -0
  155. package/template/apps/web/src/components/alert/alert.tsx +50 -0
  156. package/template/apps/web/src/components/avatar/avatar.stories.tsx +116 -0
  157. package/template/apps/web/src/components/avatar/avatar.tsx +88 -0
  158. package/template/apps/web/src/components/button/button-variants.ts +28 -0
  159. package/template/apps/web/src/components/button/button.stories.tsx +33 -0
  160. package/template/apps/web/src/components/button/button.tsx +32 -0
  161. package/template/apps/web/src/components/checkbox/checkbox.stories.tsx +312 -0
  162. package/template/apps/web/src/components/checkbox/checkbox.tsx +38 -0
  163. package/template/apps/web/src/components/dialog/danger-panel.stories.tsx +41 -0
  164. package/template/apps/web/src/components/dialog/danger-panel.tsx +32 -0
  165. package/template/apps/web/src/components/dialog/dialog.stories.tsx +239 -0
  166. package/template/apps/web/src/components/dialog/dialog.tsx +136 -0
  167. package/template/apps/web/src/components/dialog-buttons.stories.tsx +55 -0
  168. package/template/apps/web/src/components/dialog-buttons.tsx +48 -0
  169. package/template/apps/web/src/components/drawer/drawer.stories.tsx +251 -0
  170. package/template/apps/web/src/components/drawer/drawer.tsx +120 -0
  171. package/template/apps/web/src/components/dropdown-menu/dropdown-menu.stories.tsx +329 -0
  172. package/template/apps/web/src/components/dropdown-menu/dropdown-menu.tsx +263 -0
  173. package/template/apps/web/src/components/empty-state.stories.tsx +49 -0
  174. package/template/apps/web/src/components/empty-state.tsx +33 -0
  175. package/template/apps/web/src/components/field-group.stories.tsx +47 -0
  176. package/template/apps/web/src/components/field-group.tsx +20 -0
  177. package/template/apps/web/src/components/field-label.stories.tsx +34 -0
  178. package/template/apps/web/src/components/field-label.tsx +28 -0
  179. package/template/apps/web/src/components/icons.tsx +25 -0
  180. package/template/apps/web/src/components/input/input-otp.stories.tsx +86 -0
  181. package/template/apps/web/src/components/input/input-otp.tsx +184 -0
  182. package/template/apps/web/src/components/input/input.stories.tsx +35 -0
  183. package/template/apps/web/src/components/input/input.tsx +180 -0
  184. package/template/apps/web/src/components/popover/popover.stories.tsx +479 -0
  185. package/template/apps/web/src/components/popover/popover.tsx +45 -0
  186. package/template/apps/web/src/components/responsive-dialog/responsive-dialog.stories.tsx +307 -0
  187. package/template/apps/web/src/components/responsive-dialog/responsive-dialog.tsx +96 -0
  188. package/template/apps/web/src/components/searchbar/searchbar.stories.tsx +59 -0
  189. package/template/apps/web/src/components/searchbar/searchbar.tsx +64 -0
  190. package/template/apps/web/src/components/select/select.stories.tsx +293 -0
  191. package/template/apps/web/src/components/select/select.tsx +445 -0
  192. package/template/apps/web/src/components/skeleton/skeleton.stories.tsx +76 -0
  193. package/template/apps/web/src/components/skeleton/skeleton.tsx +13 -0
  194. package/template/apps/web/src/components/spinner/spinner.tsx +18 -0
  195. package/template/apps/web/src/components/switch/switch.stories.tsx +71 -0
  196. package/template/apps/web/src/components/switch/switch.tsx +35 -0
  197. package/template/apps/web/src/components/tabs/tabs.stories.tsx +209 -0
  198. package/template/apps/web/src/components/tabs/tabs.tsx +69 -0
  199. package/template/apps/web/src/components/tabs/url-tabs.stories.tsx +68 -0
  200. package/template/apps/web/src/components/tabs/url-tabs.tsx +42 -0
  201. package/template/apps/web/src/components/textarea/textarea.stories.tsx +144 -0
  202. package/template/apps/web/src/components/textarea/textarea.tsx +115 -0
  203. package/template/apps/web/src/components/toast/toaster.tsx +38 -0
  204. package/template/apps/web/src/components/tooltip/tooltip.stories.tsx +237 -0
  205. package/template/apps/web/src/components/tooltip/tooltip.tsx +49 -0
  206. package/template/apps/web/src/config/csp.ts +59 -0
  207. package/template/apps/web/src/config/routes.ts +14 -0
  208. package/template/apps/web/src/config/site.ts +5 -0
  209. package/template/apps/web/src/env.ts +15 -0
  210. package/template/apps/web/src/features/auth/auth-card.tsx +20 -0
  211. package/template/apps/web/src/features/auth/auth-divider.tsx +14 -0
  212. package/template/apps/web/src/features/auth/google-auth-button.tsx +49 -0
  213. package/template/apps/web/src/features/auth/google-one-tap.tsx +23 -0
  214. package/template/apps/web/src/features/auth/lib/auth-error.test.ts +22 -0
  215. package/template/apps/web/src/features/auth/lib/auth-error.ts +16 -0
  216. package/template/apps/web/src/features/auth/lib/return-to.test.ts +41 -0
  217. package/template/apps/web/src/features/auth/lib/return-to.ts +19 -0
  218. package/template/apps/web/src/features/auth/lib/validator.test.ts +45 -0
  219. package/template/apps/web/src/features/auth/lib/validator.ts +49 -0
  220. package/template/apps/web/src/features/auth/passkey-settings.tsx +106 -0
  221. package/template/apps/web/src/features/auth/recovery-form.tsx +61 -0
  222. package/template/apps/web/src/features/auth/resend-verification-button.tsx +36 -0
  223. package/template/apps/web/src/features/auth/reset-password-form.tsx +62 -0
  224. package/template/apps/web/src/features/auth/sign-in-form.stories.tsx +22 -0
  225. package/template/apps/web/src/features/auth/sign-in-form.tsx +116 -0
  226. package/template/apps/web/src/features/auth/sign-out-button.tsx +26 -0
  227. package/template/apps/web/src/features/auth/sign-up-form.tsx +107 -0
  228. package/template/apps/web/src/features/shared/provider.tsx +16 -0
  229. package/template/apps/web/src/features/shared/site-header.tsx +48 -0
  230. package/template/apps/web/src/features/shared/theme-toggle.tsx +24 -0
  231. package/template/apps/web/src/i18n/navigation.ts +5 -0
  232. package/template/apps/web/src/i18n/request.ts +21 -0
  233. package/template/apps/web/src/i18n/routing.ts +6 -0
  234. package/template/apps/web/src/lib/auth-client.ts +19 -0
  235. package/template/apps/web/src/lib/auth-server.ts +46 -0
  236. package/template/apps/web/src/lib/auth.ts +8 -0
  237. package/template/apps/web/src/lib/codegen.ts +30 -0
  238. package/template/apps/web/src/lib/graphql-client.ts +29 -0
  239. package/template/apps/web/src/lib/graphql-server.ts +28 -0
  240. package/template/apps/web/src/lib/graphql-utils.test.ts +32 -0
  241. package/template/apps/web/src/lib/graphql-utils.ts +76 -0
  242. package/template/apps/web/src/lib/hooks/use-media-query.ts +21 -0
  243. package/template/apps/web/src/lib/metadata.ts +17 -0
  244. package/template/apps/web/src/lib/network-error.test.ts +30 -0
  245. package/template/apps/web/src/lib/network-error.ts +17 -0
  246. package/template/apps/web/src/lib/proxy-utils.ts +42 -0
  247. package/template/apps/web/src/lib/query-client.ts +29 -0
  248. package/template/apps/web/src/lib/utils.ts +5 -0
  249. package/template/apps/web/src/proxy.ts +57 -0
  250. package/template/apps/web/test-results/.last-run.json +4 -0
  251. package/template/apps/web/tests/auth.spec.ts +17 -0
  252. package/template/apps/web/tests/csp-theme-script.spec.ts +37 -0
  253. package/template/apps/web/tests/home.spec.ts +25 -0
  254. package/template/apps/web/tsconfig.build.json +4 -0
  255. package/template/apps/web/tsconfig.json +28 -0
  256. package/template/apps/web/vitest.config.ts +40 -0
  257. package/template/apps/web/vitest.shims.d.ts +1 -0
  258. package/template/apps/worker/.env.example +4 -0
  259. package/template/apps/worker/AGENTS.md +11 -0
  260. package/template/apps/worker/Dockerfile +35 -0
  261. package/template/apps/worker/knip.config.ts +3 -0
  262. package/template/apps/worker/kustomize/base/application.properties +4 -0
  263. package/template/apps/worker/kustomize/base/deployment.yaml +62 -0
  264. package/template/apps/worker/kustomize/base/kustomization.yaml +16 -0
  265. package/template/apps/worker/kustomize/overlays/prod/kustomization.yaml +12 -0
  266. package/template/apps/worker/kustomize/overlays/staging/application.properties +1 -0
  267. package/template/apps/worker/kustomize/overlays/staging/kustomization.yaml +18 -0
  268. package/template/apps/worker/oxfmt.config.ts +8 -0
  269. package/template/apps/worker/oxlint.config.ts +15 -0
  270. package/template/apps/worker/package.json +33 -0
  271. package/template/apps/worker/sonar-project.properties +8 -0
  272. package/template/apps/worker/src/env.ts +13 -0
  273. package/template/apps/worker/src/lib/graceful-shutdown.ts +27 -0
  274. package/template/apps/worker/src/lib/logger.ts +20 -0
  275. package/template/apps/worker/src/main.ts +17 -0
  276. package/template/apps/worker/src/processors/example-processor.ts +15 -0
  277. package/template/apps/worker/src/worker/redis.ts +19 -0
  278. package/template/apps/worker/src/worker/worker.ts +37 -0
  279. package/template/apps/worker/tests/example-processor.test.ts +13 -0
  280. package/template/apps/worker/tests/graceful-shutdown.test.ts +34 -0
  281. package/template/apps/worker/tsconfig.json +4 -0
  282. package/template/apps/worker/vitest.config.ts +13 -0
  283. package/template/bun.lock +3053 -0
  284. package/template/bunfig.toml +2 -0
  285. package/template/compose.yaml +53 -0
  286. package/template/docker-bake.hcl +86 -0
  287. package/template/package.json +47 -0
  288. package/template/packages/lint/knip.ts +40 -0
  289. package/template/packages/lint/oxfmt.ts +8 -0
  290. package/template/packages/lint/oxlint.ts +136 -0
  291. package/template/packages/lint/package.json +13 -0
  292. package/template/packages/queues/oxfmt.config.ts +8 -0
  293. package/template/packages/queues/oxlint.config.ts +7 -0
  294. package/template/packages/queues/package.json +15 -0
  295. package/template/packages/queues/src/example-job.ts +11 -0
  296. package/template/packages/queues/src/index.ts +3 -0
  297. package/template/packages/queues/src/queue-names.ts +2 -0
  298. package/template/packages/queues/src/redis-connection.ts +27 -0
  299. package/template/packages/queues/tsconfig.json +4 -0
  300. package/template/packages/tsconfig/base.json +23 -0
  301. package/template/packages/tsconfig/node.json +11 -0
  302. package/template/packages/tsconfig/package.json +4 -0
  303. package/template/turbo.json +50 -0
@@ -0,0 +1,445 @@
1
+ "use client";
2
+
3
+ import * as SelectPrimitive from "@radix-ui/react-select";
4
+ import { Check, ChevronDown, ChevronUp } from "lucide-react";
5
+ import { useTranslations } from "next-intl";
6
+ import type {
7
+ ComponentPropsWithoutRef,
8
+ ComponentRef,
9
+ FocusEvent,
10
+ KeyboardEvent,
11
+ PointerEvent,
12
+ ReactNode,
13
+ RefObject,
14
+ } from "react";
15
+ import { useCallback, useMemo, useRef, useState } from "react";
16
+ import { cn } from "@/lib/utils";
17
+ import { Input } from "../input/input";
18
+ import { Spinner } from "../spinner/spinner";
19
+
20
+ const isArrowNavigationKey = (key: string) => key === "ArrowDown" || key === "ArrowUp";
21
+
22
+ const Select = SelectPrimitive.Root;
23
+
24
+ const SelectGroup = SelectPrimitive.Group;
25
+
26
+ const SelectValue = SelectPrimitive.Value;
27
+
28
+ const TRIGGER_BASE =
29
+ "flex w-full items-center justify-between gap-2.5 rounded-xl border bg-card text-left transition-[border-color,box-shadow] duration-150 outline-none";
30
+ const TRIGGER_SIZE = { md: "h-11 px-3.5 text-base", sm: "h-9 px-3 text-sm" } as const;
31
+ const TRIGGER_STATE = {
32
+ default:
33
+ "border-input shadow-xs hover:border-ring/60 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/25 data-[state=open]:border-ring data-[state=open]:ring-2 data-[state=open]:ring-ring/25",
34
+ disabled: "cursor-not-allowed border-input bg-muted/50 shadow-none dark:bg-muted/30",
35
+ error:
36
+ "border-destructive/60 shadow-xs focus-visible:border-destructive focus-visible:ring-2 focus-visible:ring-destructive/20 data-[state=open]:border-destructive data-[state=open]:ring-2 data-[state=open]:ring-destructive/20",
37
+ } as const;
38
+
39
+ function SelectTrigger({
40
+ children,
41
+ className,
42
+ disabled,
43
+ error,
44
+ ref,
45
+ size = "md",
46
+ ...props
47
+ }: ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
48
+ error?: boolean;
49
+ ref?: RefObject<ComponentRef<typeof SelectPrimitive.Trigger> | null>;
50
+ size?: "md" | "sm";
51
+ }) {
52
+ const state = disabled ? "disabled" : error ? "error" : "default";
53
+
54
+ return (
55
+ <SelectPrimitive.Trigger
56
+ aria-invalid={error}
57
+ className={cn(
58
+ "group",
59
+ TRIGGER_BASE,
60
+ TRIGGER_SIZE[size],
61
+ TRIGGER_STATE[state],
62
+ disabled && "opacity-70",
63
+ className,
64
+ )}
65
+ disabled={disabled}
66
+ ref={ref}
67
+ {...props}
68
+ >
69
+ <span className="min-w-0 flex-1 truncate text-left">{children}</span>
70
+ <SelectPrimitive.Icon asChild>
71
+ <ChevronDown
72
+ className={cn(
73
+ "text-muted-foreground size-4 shrink-0 transition-transform duration-200",
74
+ "group-data-[state=open]:rotate-180",
75
+ disabled && "text-muted-foreground/40",
76
+ )}
77
+ />
78
+ </SelectPrimitive.Icon>
79
+ </SelectPrimitive.Trigger>
80
+ );
81
+ }
82
+ SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
83
+
84
+ function SelectScrollUpButton({
85
+ className,
86
+ ref,
87
+ ...props
88
+ }: ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> & {
89
+ ref?: RefObject<ComponentRef<typeof SelectPrimitive.ScrollUpButton> | null>;
90
+ }) {
91
+ const t = useTranslations("Common");
92
+
93
+ return (
94
+ <SelectPrimitive.ScrollUpButton
95
+ aria-label={t("a11y.scrollUp")}
96
+ className={cn("flex cursor-default items-center justify-center py-1", className)}
97
+ ref={ref}
98
+ {...props}
99
+ >
100
+ <ChevronUp className="h-4 w-4" />
101
+ </SelectPrimitive.ScrollUpButton>
102
+ );
103
+ }
104
+ SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
105
+
106
+ function SelectScrollDownButton({
107
+ className,
108
+ ref,
109
+ ...props
110
+ }: ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> & {
111
+ ref?: RefObject<ComponentRef<typeof SelectPrimitive.ScrollDownButton> | null>;
112
+ }) {
113
+ const t = useTranslations("Common");
114
+
115
+ return (
116
+ <SelectPrimitive.ScrollDownButton
117
+ aria-label={t("a11y.scrollDown")}
118
+ className={cn("flex cursor-default items-center justify-center py-1", className)}
119
+ ref={ref}
120
+ {...props}
121
+ >
122
+ <ChevronDown className="h-4 w-4" />
123
+ </SelectPrimitive.ScrollDownButton>
124
+ );
125
+ }
126
+ SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
127
+
128
+ function SelectContent({
129
+ children,
130
+ className,
131
+ onViewportScroll,
132
+ position = "popper",
133
+ ref,
134
+ search,
135
+ ...props
136
+ }: ComponentPropsWithoutRef<typeof SelectPrimitive.Content> & {
137
+ onViewportScroll?: React.UIEventHandler<HTMLDivElement>;
138
+ ref?: RefObject<ComponentRef<typeof SelectPrimitive.Content> | null>;
139
+ search?: React.ReactNode;
140
+ }) {
141
+ return (
142
+ <SelectPrimitive.Portal>
143
+ <SelectPrimitive.Content
144
+ className={cn(
145
+ "relative z-50 max-h-96 min-w-32 overflow-hidden rounded-xl",
146
+ "border-border bg-popover text-popover-foreground border shadow-lg shadow-black/8",
147
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
148
+ position === "popper" &&
149
+ "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
150
+ className,
151
+ )}
152
+ position={position}
153
+ ref={ref}
154
+ {...props}
155
+ >
156
+ {search}
157
+ <SelectScrollUpButton />
158
+ <SelectPrimitive.Viewport
159
+ className={cn(
160
+ "p-1",
161
+ position === "popper" &&
162
+ "h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)",
163
+ )}
164
+ onScroll={onViewportScroll}
165
+ >
166
+ {children}
167
+ </SelectPrimitive.Viewport>
168
+ <SelectScrollDownButton />
169
+ </SelectPrimitive.Content>
170
+ </SelectPrimitive.Portal>
171
+ );
172
+ }
173
+ SelectContent.displayName = SelectPrimitive.Content.displayName;
174
+
175
+ function SelectLabel({
176
+ className,
177
+ ref,
178
+ ...props
179
+ }: ComponentPropsWithoutRef<typeof SelectPrimitive.Label> & {
180
+ ref?: RefObject<ComponentRef<typeof SelectPrimitive.Label> | null>;
181
+ }) {
182
+ return (
183
+ <SelectPrimitive.Label
184
+ className={cn("text-muted-foreground px-3 py-1.5 text-xs font-medium", className)}
185
+ ref={ref}
186
+ {...props}
187
+ />
188
+ );
189
+ }
190
+ SelectLabel.displayName = SelectPrimitive.Label.displayName;
191
+
192
+ function SelectItem({
193
+ children,
194
+ className,
195
+ ref,
196
+ ...props
197
+ }: ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
198
+ ref?: RefObject<ComponentRef<typeof SelectPrimitive.Item> | null>;
199
+ }) {
200
+ return (
201
+ <SelectPrimitive.Item
202
+ className={cn(
203
+ "relative flex w-full cursor-pointer items-center rounded-lg py-2 pr-8 pl-3 text-sm outline-none select-none",
204
+ "transition-colors duration-150",
205
+ "focus:bg-accent focus:text-accent-foreground",
206
+ "data-disabled:pointer-events-none data-disabled:opacity-50",
207
+ className,
208
+ )}
209
+ ref={ref}
210
+ {...props}
211
+ >
212
+ <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
213
+ <SelectPrimitive.ItemIndicator>
214
+ <Check className="text-primary size-4" />
215
+ </SelectPrimitive.ItemIndicator>
216
+ </span>
217
+ <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
218
+ </SelectPrimitive.Item>
219
+ );
220
+ }
221
+ SelectItem.displayName = SelectPrimitive.Item.displayName;
222
+
223
+ function SelectSeparator({
224
+ className,
225
+ ref,
226
+ ...props
227
+ }: ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> & {
228
+ ref?: RefObject<ComponentRef<typeof SelectPrimitive.Separator> | null>;
229
+ }) {
230
+ return (
231
+ <SelectPrimitive.Separator
232
+ className={cn("bg-border -mx-1 my-1 h-px", className)}
233
+ ref={ref}
234
+ {...props}
235
+ />
236
+ );
237
+ }
238
+ SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
239
+
240
+ export type SelectOption = {
241
+ icon?: ReactNode;
242
+ label: string;
243
+ trailing?: ReactNode;
244
+ value: string;
245
+ };
246
+
247
+ type SelectWithSearchProps = {
248
+ className?: string;
249
+ disabled?: boolean;
250
+ id?: string;
251
+ isLoadingMore?: boolean;
252
+ isSearching?: boolean;
253
+ noResultsText?: string;
254
+ onLoadMore?: () => void;
255
+ onOpenChange?: (open: boolean) => void;
256
+ onSearchChange?: (query: string) => void;
257
+ onValueChange?: (value: string) => void;
258
+ options: SelectOption[];
259
+ placeholder?: string;
260
+ searchingText?: string;
261
+ searchPlaceholder?: string;
262
+ searchQuery?: string;
263
+ selectedOption?: SelectOption;
264
+ size?: "md" | "sm";
265
+ value?: string;
266
+ };
267
+
268
+ function SelectWithSearch({
269
+ className,
270
+ disabled = false,
271
+ id,
272
+ isLoadingMore = false,
273
+ isSearching = false,
274
+ noResultsText = "No results found",
275
+ onLoadMore,
276
+ onOpenChange,
277
+ onSearchChange,
278
+ onValueChange,
279
+ options,
280
+ placeholder = "Select an option",
281
+ searchingText = "Searching...",
282
+ searchPlaceholder = "Search...",
283
+ searchQuery: controlledSearchQuery,
284
+ selectedOption,
285
+ size = "md",
286
+ value,
287
+ }: SelectWithSearchProps) {
288
+ const [localSearchQuery, setLocalSearchQuery] = useState("");
289
+ const searchQuery = controlledSearchQuery ?? localSearchQuery;
290
+ const setSearchQuery = onSearchChange ?? setLocalSearchQuery;
291
+ const searchContainerRef = useRef<HTMLDivElement>(null);
292
+ const allowBlurRef = useRef(false);
293
+
294
+ const filteredOptions = useMemo(
295
+ () =>
296
+ onSearchChange
297
+ ? options
298
+ : options.filter((option) =>
299
+ option.label.toLowerCase().includes(searchQuery.toLowerCase()),
300
+ ),
301
+ [onSearchChange, options, searchQuery],
302
+ );
303
+ const currentOption =
304
+ selectedOption?.value === value
305
+ ? selectedOption
306
+ : options.find((option) => option.value === value);
307
+
308
+ const renderOptionContent = (option: SelectOption) => {
309
+ if (!option) {
310
+ return null;
311
+ }
312
+ if (!option.icon && !option.trailing) {
313
+ return option.label;
314
+ }
315
+ return (
316
+ <div className="flex items-center gap-2">
317
+ {option.icon}
318
+ <span className="min-w-0 truncate">{option.label}</span>
319
+ {option.trailing != null && <span className="ml-auto shrink-0">{option.trailing}</span>}
320
+ </div>
321
+ );
322
+ };
323
+
324
+ const handleSearchKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
325
+ if (isArrowNavigationKey(event.key)) {
326
+ allowBlurRef.current = true;
327
+ return;
328
+ }
329
+ event.stopPropagation();
330
+ if ("stopImmediatePropagation" in event.nativeEvent) {
331
+ event.nativeEvent.stopImmediatePropagation();
332
+ }
333
+ allowBlurRef.current = false;
334
+ }, []);
335
+
336
+ const handleOpenChange = (open: boolean) => {
337
+ onOpenChange?.(open);
338
+ if (!open) {
339
+ setSearchQuery("");
340
+ return;
341
+ }
342
+ requestAnimationFrame(() => {
343
+ const inputElement = searchContainerRef.current?.querySelector<HTMLInputElement>("input");
344
+ inputElement?.focus();
345
+ inputElement?.select();
346
+ });
347
+ };
348
+
349
+ const handleContentPointerDown = useCallback((event: PointerEvent<HTMLDivElement>) => {
350
+ const clickedInsideSearch = searchContainerRef.current?.contains(event.target as Node);
351
+ allowBlurRef.current = !clickedInsideSearch;
352
+ }, []);
353
+
354
+ const handleSearchBlur = useCallback((event: FocusEvent<HTMLInputElement>) => {
355
+ if (allowBlurRef.current) {
356
+ allowBlurRef.current = false;
357
+ return;
358
+ }
359
+ event.preventDefault();
360
+ requestAnimationFrame(() => {
361
+ event.target.focus();
362
+ });
363
+ }, []);
364
+
365
+ const searchInput = (
366
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions
367
+ <div
368
+ className="p-1"
369
+ onMouseDown={(e) => {
370
+ e.stopPropagation();
371
+ }}
372
+ ref={searchContainerRef}
373
+ >
374
+ <Input
375
+ aria-label={searchPlaceholder}
376
+ className="w-full"
377
+ onBlur={handleSearchBlur}
378
+ onChange={(e) => setSearchQuery(e.target.value)}
379
+ onKeyDown={handleSearchKeyDown}
380
+ placeholder={searchPlaceholder}
381
+ size="sm"
382
+ value={searchQuery}
383
+ />
384
+ </div>
385
+ );
386
+
387
+ return (
388
+ <Select
389
+ disabled={disabled}
390
+ onOpenChange={handleOpenChange}
391
+ onValueChange={onValueChange}
392
+ value={value}
393
+ >
394
+ <SelectTrigger aria-label={placeholder} className={className} id={id} size={size}>
395
+ <SelectValue placeholder={placeholder}>
396
+ {currentOption ? renderOptionContent(currentOption) : undefined}
397
+ </SelectValue>
398
+ </SelectTrigger>
399
+ <SelectContent
400
+ aria-label={placeholder}
401
+ onPointerDown={handleContentPointerDown}
402
+ onViewportScroll={
403
+ onLoadMore
404
+ ? (event) => {
405
+ const viewport = event.currentTarget;
406
+ if (viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 48) {
407
+ onLoadMore();
408
+ }
409
+ }
410
+ : undefined
411
+ }
412
+ search={searchInput}
413
+ >
414
+ {isSearching && filteredOptions.length === 0 && (
415
+ <div className="text-muted-foreground p-4 text-center text-sm">{searchingText}</div>
416
+ )}
417
+ {!isSearching && filteredOptions.length === 0 && (
418
+ <div className="text-muted-foreground p-4 text-center text-sm">{noResultsText}</div>
419
+ )}
420
+ {filteredOptions.map((option) => (
421
+ <SelectItem key={option.value} value={option.value}>
422
+ {renderOptionContent(option)}
423
+ </SelectItem>
424
+ ))}
425
+ {isLoadingMore && (
426
+ <div className="flex items-center justify-center py-2">
427
+ <Spinner size={16} />
428
+ </div>
429
+ )}
430
+ </SelectContent>
431
+ </Select>
432
+ );
433
+ }
434
+
435
+ export {
436
+ Select,
437
+ SelectContent,
438
+ SelectGroup,
439
+ SelectItem,
440
+ SelectLabel,
441
+ SelectSeparator,
442
+ SelectTrigger,
443
+ SelectValue,
444
+ SelectWithSearch,
445
+ };
@@ -0,0 +1,76 @@
1
+ import type { Meta, StoryObj } from "@storybook/nextjs-vite";
2
+ import { expect } from "storybook/test";
3
+ import { Skeleton } from "./skeleton";
4
+
5
+ const meta: Meta<typeof Skeleton> = {
6
+ argTypes: {
7
+ className: {
8
+ control: "text",
9
+ },
10
+ },
11
+ component: Skeleton,
12
+ parameters: { layout: "centered" },
13
+ tags: ["autodocs"],
14
+ };
15
+
16
+ export default meta;
17
+ type Story = StoryObj<typeof meta>;
18
+
19
+ export const Default: Story = {
20
+ play: async ({ canvasElement }) => {
21
+ await expect(canvasElement.querySelectorAll('[data-slot="skeleton"]')).toHaveLength(3);
22
+ },
23
+ render: () => (
24
+ <div className="w-80 space-y-4">
25
+ <Skeleton className="h-12 w-full" />
26
+ <Skeleton className="h-4 w-3/4" />
27
+ <Skeleton className="h-4 w-1/2" />
28
+ </div>
29
+ ),
30
+ };
31
+
32
+ export const Circle: Story = {
33
+ render: () => <Skeleton className="size-12 rounded-full" />,
34
+ };
35
+
36
+ export const Text: Story = {
37
+ render: () => (
38
+ <div className="w-80 space-y-2">
39
+ <Skeleton className="h-4 w-full" />
40
+ <Skeleton className="h-4 w-full" />
41
+ <Skeleton className="h-4 w-2/3" />
42
+ </div>
43
+ ),
44
+ };
45
+
46
+ export const Card: Story = {
47
+ render: () => (
48
+ <div className="border-border w-80 space-y-3 rounded-xl border p-4">
49
+ <Skeleton className="size-12 rounded-full" />
50
+ <div className="space-y-2">
51
+ <Skeleton className="h-4 w-3/4" />
52
+ <Skeleton className="h-4 w-1/2" />
53
+ </div>
54
+ <Skeleton className="h-24 w-full" />
55
+ </div>
56
+ ),
57
+ };
58
+
59
+ export const List: Story = {
60
+ render: () => (
61
+ <div className="w-80 space-y-2">
62
+ {Array.from({ length: 5 }).map((_, i) => (
63
+ <div
64
+ className="border-border flex items-center gap-3 rounded-lg border p-3"
65
+ key={`${i - 1}`}
66
+ >
67
+ <Skeleton className="size-10 shrink-0 rounded-md" />
68
+ <div className="min-w-0 flex-1 space-y-2">
69
+ <Skeleton className="h-3 w-2/3" />
70
+ <Skeleton className="h-2.5 w-1/2" />
71
+ </div>
72
+ </div>
73
+ ))}
74
+ </div>
75
+ ),
76
+ };
@@ -0,0 +1,13 @@
1
+ import { cn } from "@/lib/utils";
2
+
3
+ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
4
+ return (
5
+ <div
6
+ className={cn("rounded-md skeleton-blue opacity-10", className)}
7
+ data-slot="skeleton"
8
+ {...props}
9
+ />
10
+ );
11
+ }
12
+
13
+ export { Skeleton };
@@ -0,0 +1,18 @@
1
+ import { Loader2 } from "lucide-react";
2
+ import type { SVGProps } from "react";
3
+ import { cn } from "@/lib/utils";
4
+
5
+ type SpinnerProps = SVGProps<SVGSVGElement> & { size?: number };
6
+
7
+ export function Spinner({ className, size = 24, ...props }: Readonly<SpinnerProps>) {
8
+ return (
9
+ <Loader2
10
+ aria-label={props["aria-hidden"] ? undefined : (props["aria-label"] ?? "Loading")}
11
+ className={cn("animate-spin", className)}
12
+ height={size}
13
+ role={props["aria-hidden"] ? undefined : "status"}
14
+ width={size}
15
+ {...props}
16
+ />
17
+ );
18
+ }
@@ -0,0 +1,71 @@
1
+ import type { Meta, StoryObj } from "@storybook/nextjs-vite";
2
+ import { useState } from "react";
3
+ import { expect, fn, userEvent, within } from "storybook/test";
4
+ import { Switch } from "./switch";
5
+
6
+ const meta = {
7
+ args: {
8
+ "aria-label": "Email notifications",
9
+ checked: false,
10
+ disabled: false,
11
+ onCheckedChange: fn(),
12
+ },
13
+ argTypes: {
14
+ checked: { control: "boolean" },
15
+ disabled: { control: "boolean" },
16
+ },
17
+ component: Switch,
18
+ parameters: { layout: "centered" },
19
+ tags: ["autodocs"],
20
+ } satisfies Meta<typeof Switch>;
21
+
22
+ export default meta;
23
+
24
+ type Story = StoryObj<typeof meta>;
25
+
26
+ export const Off: Story = {
27
+ play: async ({ canvasElement }) => {
28
+ const toggle = within(canvasElement).getByRole("switch");
29
+
30
+ await expect(toggle).toHaveAttribute("aria-checked", "false");
31
+ },
32
+ };
33
+
34
+ export const On: Story = {
35
+ args: { checked: true },
36
+ play: async ({ canvasElement }) => {
37
+ const toggle = within(canvasElement).getByRole("switch");
38
+
39
+ await expect(toggle).toHaveAttribute("aria-checked", "true");
40
+ },
41
+ };
42
+
43
+ export const Disabled: Story = {
44
+ args: { disabled: true },
45
+ play: async ({ args, canvasElement }) => {
46
+ const toggle = within(canvasElement).getByRole("switch");
47
+
48
+ await expect(toggle).toBeDisabled();
49
+
50
+ await userEvent.click(toggle);
51
+
52
+ await expect(args.onCheckedChange).not.toHaveBeenCalled();
53
+ },
54
+ };
55
+
56
+ export const Interactive: Story = {
57
+ play: async ({ canvasElement }) => {
58
+ const toggle = within(canvasElement).getByRole("switch");
59
+
60
+ await expect(toggle).toHaveAttribute("aria-checked", "false");
61
+
62
+ await userEvent.click(toggle);
63
+
64
+ await expect(toggle).toHaveAttribute("aria-checked", "true");
65
+ },
66
+ render: (args) => {
67
+ const [checked, setChecked] = useState(false);
68
+
69
+ return <Switch {...args} checked={checked} onCheckedChange={setChecked} />;
70
+ },
71
+ };
@@ -0,0 +1,35 @@
1
+ "use client";
2
+
3
+ import type { ButtonHTMLAttributes } from "react";
4
+ import { cn } from "@/lib/utils";
5
+
6
+ type Props = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> & {
7
+ checked: boolean;
8
+ onCheckedChange: (checked: boolean) => void;
9
+ };
10
+
11
+ export function Switch({ checked, className, disabled, onCheckedChange, ...props }: Props) {
12
+ return (
13
+ <button
14
+ aria-checked={checked}
15
+ className={cn(
16
+ "focus-visible:ring-ring/40 relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border transition-colors outline-none focus-visible:ring-2",
17
+ checked ? "bg-primary border-primary" : "bg-muted border-border",
18
+ disabled && "cursor-not-allowed opacity-50",
19
+ className,
20
+ )}
21
+ disabled={disabled}
22
+ onClick={() => onCheckedChange(!checked)}
23
+ role="switch"
24
+ type="button"
25
+ {...props}
26
+ >
27
+ <span
28
+ className={cn(
29
+ "pointer-events-none block size-4 rounded-full bg-white shadow-sm transition-transform",
30
+ checked ? "translate-x-4" : "translate-x-0.5",
31
+ )}
32
+ />
33
+ </button>
34
+ );
35
+ }