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.
- package/dist/index.js +1727 -0
- package/package.json +37 -0
- package/template/.agents/plugins/marketplace.json +21 -0
- package/template/.claude/settings.json +6 -0
- package/template/.claude/skills/frontend-design/LICENSE.txt +177 -0
- package/template/.claude/skills/frontend-design/SKILL.md +154 -0
- package/template/.claude/skills/theme-factory/SKILL.md +95 -0
- package/template/.claude/skills/theme-factory/references/schema.md +104 -0
- package/template/.claude/skills/theme-factory/scripts/theme.mjs +261 -0
- package/template/.dockerignore +43 -0
- package/template/.editorconfig +10 -0
- package/template/.github/workflows/ci.yaml +289 -0
- package/template/.github/workflows/publish-app.yaml +66 -0
- package/template/.github/workflows/publish.yaml +148 -0
- package/template/.github/workflows/snyk.yaml +34 -0
- package/template/.vscode/settings.json +8 -0
- package/template/AGENTS.md +42 -0
- package/template/Dockerfile.base +9 -0
- package/template/README.md +54 -0
- package/template/_gitignore +10 -0
- package/template/apps/api/.env.example +32 -0
- package/template/apps/api/AGENTS.md +44 -0
- package/template/apps/api/Dockerfile +69 -0
- package/template/apps/api/_gitignore +8 -0
- package/template/apps/api/knip.config.ts +22 -0
- package/template/apps/api/kustomize/base/application.properties +16 -0
- package/template/apps/api/kustomize/base/deployment.yaml +94 -0
- package/template/apps/api/kustomize/base/kustomization.yaml +17 -0
- package/template/apps/api/kustomize/base/service.yaml +10 -0
- package/template/apps/api/kustomize/overlays/prod/kustomization.yaml +17 -0
- package/template/apps/api/kustomize/overlays/staging/application.properties +1 -0
- package/template/apps/api/kustomize/overlays/staging/kustomization.yaml +21 -0
- package/template/apps/api/kustomize/secret.keys +14 -0
- package/template/apps/api/oxfmt.config.ts +8 -0
- package/template/apps/api/oxlint.config.ts +107 -0
- package/template/apps/api/package.json +79 -0
- package/template/apps/api/prisma/migrations/20260927000000_init/migration.sql +113 -0
- package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
- package/template/apps/api/prisma/schema.prisma +113 -0
- package/template/apps/api/prisma.config.ts +7 -0
- package/template/apps/api/scripts/auth.cli.ts +19 -0
- package/template/apps/api/scripts/seal-secret.sh +171 -0
- package/template/apps/api/sonar-project.properties +9 -0
- package/template/apps/api/src/app-info/app-info.dto.ts +10 -0
- package/template/apps/api/src/app-info/app-info.module.ts +7 -0
- package/template/apps/api/src/app-info/app-info.resolver.spec.ts +14 -0
- package/template/apps/api/src/app-info/app-info.resolver.ts +21 -0
- package/template/apps/api/src/app.module.spec.ts +93 -0
- package/template/apps/api/src/app.module.ts +77 -0
- package/template/apps/api/src/auth/auth.guard.spec.ts +90 -0
- package/template/apps/api/src/auth/auth.guard.ts +97 -0
- package/template/apps/api/src/auth/auth.ts +129 -0
- package/template/apps/api/src/auth/decorator/allow-anonymous.decorator.ts +17 -0
- package/template/apps/api/src/auth/decorator/is-email-verified.decorator.spec.ts +41 -0
- package/template/apps/api/src/auth/decorator/is-email-verified.decorator.ts +20 -0
- package/template/apps/api/src/auth/decorator/roles.decorator.ts +27 -0
- package/template/apps/api/src/auth/testing/make-session.ts +31 -0
- package/template/apps/api/src/auth/types/auth.types.ts +27 -0
- package/template/apps/api/src/bootstrap/register-graphql-enums.ts +12 -0
- package/template/apps/api/src/bootstrap/security.ts +24 -0
- package/template/apps/api/src/bootstrap/swagger.ts +28 -0
- package/template/apps/api/src/config/app.config.spec.ts +22 -0
- package/template/apps/api/src/config/app.config.ts +122 -0
- package/template/apps/api/src/config/graphql-depth-limit.ts +103 -0
- package/template/apps/api/src/config/graphql.config.ts +77 -0
- package/template/apps/api/src/config/resources/app.yaml +29 -0
- package/template/apps/api/src/errors/app-parse-uuid.pipe.ts +27 -0
- package/template/apps/api/src/errors/app.exception.ts +47 -0
- package/template/apps/api/src/errors/error-code.enum.ts +54 -0
- package/template/apps/api/src/errors/http-status-fallback-code.util.ts +27 -0
- package/template/apps/api/src/errors/validation-exception.factory.ts +43 -0
- package/template/apps/api/src/health/health.controller.spec.ts +82 -0
- package/template/apps/api/src/health/health.controller.ts +57 -0
- package/template/apps/api/src/health/health.module.ts +12 -0
- package/template/apps/api/src/logging/context-logger.ts +18 -0
- package/template/apps/api/src/logging/logger.config.ts +23 -0
- package/template/apps/api/src/logging/logging.module.ts +22 -0
- package/template/apps/api/src/mail/mail.module.ts +12 -0
- package/template/apps/api/src/mail/mail.service.spec.ts +47 -0
- package/template/apps/api/src/mail/mail.service.ts +123 -0
- package/template/apps/api/src/main.ts +41 -0
- package/template/apps/api/src/prisma/prisma.module.ts +12 -0
- package/template/apps/api/src/prisma/prisma.service.spec.ts +136 -0
- package/template/apps/api/src/prisma/prisma.service.ts +138 -0
- package/template/apps/api/src/queue/dto/enqueue-example.input.ts +11 -0
- package/template/apps/api/src/queue/example-queue.resolver.ts +14 -0
- package/template/apps/api/src/queue/example-queue.service.spec.ts +21 -0
- package/template/apps/api/src/queue/example-queue.service.ts +32 -0
- package/template/apps/api/src/queue/queue.module.ts +27 -0
- package/template/apps/api/src/r2/r2.module.ts +12 -0
- package/template/apps/api/src/r2/r2.service.spec.ts +117 -0
- package/template/apps/api/src/r2/r2.service.ts +131 -0
- package/template/apps/api/src/user/dto/user.dto.ts +26 -0
- package/template/apps/api/src/user/user.module.ts +10 -0
- package/template/apps/api/src/user/user.resolver.spec.ts +18 -0
- package/template/apps/api/src/user/user.resolver.ts +24 -0
- package/template/apps/api/src/utils/catch-everything-exception.filter.spec.ts +292 -0
- package/template/apps/api/src/utils/catch-everything-exception.filter.ts +70 -0
- package/template/apps/api/src/utils/error-handler.util.spec.ts +20 -0
- package/template/apps/api/src/utils/error-handler.util.ts +12 -0
- package/template/apps/api/src/utils/escape-like-pattern.util.spec.ts +29 -0
- package/template/apps/api/src/utils/escape-like-pattern.util.ts +11 -0
- package/template/apps/api/src/utils/pagination.input.ts +20 -0
- package/template/apps/api/src/utils/prisma-exception.filter.spec.ts +186 -0
- package/template/apps/api/src/utils/prisma-exception.filter.ts +45 -0
- package/template/apps/api/src/utils/prisma-graphql-exception.filter.spec.ts +216 -0
- package/template/apps/api/src/utils/prisma-graphql-exception.filter.ts +43 -0
- package/template/apps/api/tests/app.e2e-spec.ts +35 -0
- package/template/apps/api/tests/auth.e2e-spec.ts +38 -0
- package/template/apps/api/tests/helpers/api.ts +27 -0
- package/template/apps/api/tsconfig.json +11 -0
- package/template/apps/api/vitest.config.e2e.ts +16 -0
- package/template/apps/api/vitest.config.ts +40 -0
- package/template/apps/web/.env.example +7 -0
- package/template/apps/web/.storybook/decorators.tsx +38 -0
- package/template/apps/web/.storybook/main.ts +17 -0
- package/template/apps/web/.storybook/preview.ts +38 -0
- package/template/apps/web/.storybook/vitest.setup.ts +5 -0
- package/template/apps/web/AGENTS.md +29 -0
- package/template/apps/web/Dockerfile +49 -0
- package/template/apps/web/_gitignore +16 -0
- package/template/apps/web/knip.config.ts +15 -0
- package/template/apps/web/kustomize/base/application.properties +5 -0
- package/template/apps/web/kustomize/base/deployment.yaml +60 -0
- package/template/apps/web/kustomize/base/kustomization.yaml +17 -0
- package/template/apps/web/kustomize/base/service.yaml +10 -0
- package/template/apps/web/kustomize/overlays/prod/kustomization.yaml +12 -0
- package/template/apps/web/kustomize/overlays/staging/application.properties +2 -0
- package/template/apps/web/kustomize/overlays/staging/kustomization.yaml +18 -0
- package/template/apps/web/messages/de.json +124 -0
- package/template/apps/web/messages/en.json +124 -0
- package/template/apps/web/next-env.d.ts +7 -0
- package/template/apps/web/next.config.ts +39 -0
- package/template/apps/web/oxfmt.config.ts +9 -0
- package/template/apps/web/oxlint.config.ts +36 -0
- package/template/apps/web/package.json +80 -0
- package/template/apps/web/playwright.config.ts +34 -0
- package/template/apps/web/postcss.config.mjs +7 -0
- package/template/apps/web/sonar-project.properties +9 -0
- package/template/apps/web/src/app/[locale]/(auth)/recovery/page.tsx +22 -0
- package/template/apps/web/src/app/[locale]/(auth)/reset-password/page.tsx +29 -0
- package/template/apps/web/src/app/[locale]/(auth)/sign-in/page.tsx +24 -0
- package/template/apps/web/src/app/[locale]/(auth)/sign-up/page.tsx +24 -0
- package/template/apps/web/src/app/[locale]/(auth)/verify/page.tsx +24 -0
- package/template/apps/web/src/app/[locale]/dashboard/page.tsx +34 -0
- package/template/apps/web/src/app/[locale]/layout.tsx +60 -0
- package/template/apps/web/src/app/[locale]/page.tsx +23 -0
- package/template/apps/web/src/app/api/health/route.ts +3 -0
- package/template/apps/web/src/app/global-error.tsx +19 -0
- package/template/apps/web/src/app/globals.css +98 -0
- package/template/apps/web/src/app/icon.svg +1 -0
- package/template/apps/web/src/app/robots.ts +15 -0
- package/template/apps/web/src/app/sitemap.ts +24 -0
- package/template/apps/web/src/components/alert/alert.stories.tsx +25 -0
- package/template/apps/web/src/components/alert/alert.tsx +50 -0
- package/template/apps/web/src/components/avatar/avatar.stories.tsx +116 -0
- package/template/apps/web/src/components/avatar/avatar.tsx +88 -0
- package/template/apps/web/src/components/button/button-variants.ts +28 -0
- package/template/apps/web/src/components/button/button.stories.tsx +33 -0
- package/template/apps/web/src/components/button/button.tsx +32 -0
- package/template/apps/web/src/components/checkbox/checkbox.stories.tsx +312 -0
- package/template/apps/web/src/components/checkbox/checkbox.tsx +38 -0
- package/template/apps/web/src/components/dialog/danger-panel.stories.tsx +41 -0
- package/template/apps/web/src/components/dialog/danger-panel.tsx +32 -0
- package/template/apps/web/src/components/dialog/dialog.stories.tsx +239 -0
- package/template/apps/web/src/components/dialog/dialog.tsx +136 -0
- package/template/apps/web/src/components/dialog-buttons.stories.tsx +55 -0
- package/template/apps/web/src/components/dialog-buttons.tsx +48 -0
- package/template/apps/web/src/components/drawer/drawer.stories.tsx +251 -0
- package/template/apps/web/src/components/drawer/drawer.tsx +120 -0
- package/template/apps/web/src/components/dropdown-menu/dropdown-menu.stories.tsx +329 -0
- package/template/apps/web/src/components/dropdown-menu/dropdown-menu.tsx +263 -0
- package/template/apps/web/src/components/empty-state.stories.tsx +49 -0
- package/template/apps/web/src/components/empty-state.tsx +33 -0
- package/template/apps/web/src/components/field-group.stories.tsx +47 -0
- package/template/apps/web/src/components/field-group.tsx +20 -0
- package/template/apps/web/src/components/field-label.stories.tsx +34 -0
- package/template/apps/web/src/components/field-label.tsx +28 -0
- package/template/apps/web/src/components/icons.tsx +25 -0
- package/template/apps/web/src/components/input/input-otp.stories.tsx +86 -0
- package/template/apps/web/src/components/input/input-otp.tsx +184 -0
- package/template/apps/web/src/components/input/input.stories.tsx +35 -0
- package/template/apps/web/src/components/input/input.tsx +180 -0
- package/template/apps/web/src/components/popover/popover.stories.tsx +479 -0
- package/template/apps/web/src/components/popover/popover.tsx +45 -0
- package/template/apps/web/src/components/responsive-dialog/responsive-dialog.stories.tsx +307 -0
- package/template/apps/web/src/components/responsive-dialog/responsive-dialog.tsx +96 -0
- package/template/apps/web/src/components/searchbar/searchbar.stories.tsx +59 -0
- package/template/apps/web/src/components/searchbar/searchbar.tsx +64 -0
- package/template/apps/web/src/components/select/select.stories.tsx +293 -0
- package/template/apps/web/src/components/select/select.tsx +445 -0
- package/template/apps/web/src/components/skeleton/skeleton.stories.tsx +76 -0
- package/template/apps/web/src/components/skeleton/skeleton.tsx +13 -0
- package/template/apps/web/src/components/spinner/spinner.tsx +18 -0
- package/template/apps/web/src/components/switch/switch.stories.tsx +71 -0
- package/template/apps/web/src/components/switch/switch.tsx +35 -0
- package/template/apps/web/src/components/tabs/tabs.stories.tsx +209 -0
- package/template/apps/web/src/components/tabs/tabs.tsx +69 -0
- package/template/apps/web/src/components/tabs/url-tabs.stories.tsx +68 -0
- package/template/apps/web/src/components/tabs/url-tabs.tsx +42 -0
- package/template/apps/web/src/components/textarea/textarea.stories.tsx +144 -0
- package/template/apps/web/src/components/textarea/textarea.tsx +115 -0
- package/template/apps/web/src/components/toast/toaster.tsx +38 -0
- package/template/apps/web/src/components/tooltip/tooltip.stories.tsx +237 -0
- package/template/apps/web/src/components/tooltip/tooltip.tsx +49 -0
- package/template/apps/web/src/config/csp.ts +59 -0
- package/template/apps/web/src/config/routes.ts +14 -0
- package/template/apps/web/src/config/site.ts +5 -0
- package/template/apps/web/src/env.ts +15 -0
- package/template/apps/web/src/features/auth/auth-card.tsx +20 -0
- package/template/apps/web/src/features/auth/auth-divider.tsx +14 -0
- package/template/apps/web/src/features/auth/google-auth-button.tsx +49 -0
- package/template/apps/web/src/features/auth/google-one-tap.tsx +23 -0
- package/template/apps/web/src/features/auth/lib/auth-error.test.ts +22 -0
- package/template/apps/web/src/features/auth/lib/auth-error.ts +16 -0
- package/template/apps/web/src/features/auth/lib/return-to.test.ts +41 -0
- package/template/apps/web/src/features/auth/lib/return-to.ts +19 -0
- package/template/apps/web/src/features/auth/lib/validator.test.ts +45 -0
- package/template/apps/web/src/features/auth/lib/validator.ts +49 -0
- package/template/apps/web/src/features/auth/passkey-settings.tsx +106 -0
- package/template/apps/web/src/features/auth/recovery-form.tsx +61 -0
- package/template/apps/web/src/features/auth/resend-verification-button.tsx +36 -0
- package/template/apps/web/src/features/auth/reset-password-form.tsx +62 -0
- package/template/apps/web/src/features/auth/sign-in-form.stories.tsx +22 -0
- package/template/apps/web/src/features/auth/sign-in-form.tsx +116 -0
- package/template/apps/web/src/features/auth/sign-out-button.tsx +26 -0
- package/template/apps/web/src/features/auth/sign-up-form.tsx +107 -0
- package/template/apps/web/src/features/shared/provider.tsx +16 -0
- package/template/apps/web/src/features/shared/site-header.tsx +48 -0
- package/template/apps/web/src/features/shared/theme-toggle.tsx +24 -0
- package/template/apps/web/src/i18n/navigation.ts +5 -0
- package/template/apps/web/src/i18n/request.ts +21 -0
- package/template/apps/web/src/i18n/routing.ts +6 -0
- package/template/apps/web/src/lib/auth-client.ts +19 -0
- package/template/apps/web/src/lib/auth-server.ts +46 -0
- package/template/apps/web/src/lib/auth.ts +8 -0
- package/template/apps/web/src/lib/codegen.ts +30 -0
- package/template/apps/web/src/lib/graphql-client.ts +29 -0
- package/template/apps/web/src/lib/graphql-server.ts +28 -0
- package/template/apps/web/src/lib/graphql-utils.test.ts +32 -0
- package/template/apps/web/src/lib/graphql-utils.ts +76 -0
- package/template/apps/web/src/lib/hooks/use-media-query.ts +21 -0
- package/template/apps/web/src/lib/metadata.ts +17 -0
- package/template/apps/web/src/lib/network-error.test.ts +30 -0
- package/template/apps/web/src/lib/network-error.ts +17 -0
- package/template/apps/web/src/lib/proxy-utils.ts +42 -0
- package/template/apps/web/src/lib/query-client.ts +29 -0
- package/template/apps/web/src/lib/utils.ts +5 -0
- package/template/apps/web/src/proxy.ts +57 -0
- package/template/apps/web/test-results/.last-run.json +4 -0
- package/template/apps/web/tests/auth.spec.ts +17 -0
- package/template/apps/web/tests/csp-theme-script.spec.ts +37 -0
- package/template/apps/web/tests/home.spec.ts +25 -0
- package/template/apps/web/tsconfig.build.json +4 -0
- package/template/apps/web/tsconfig.json +28 -0
- package/template/apps/web/vitest.config.ts +40 -0
- package/template/apps/web/vitest.shims.d.ts +1 -0
- package/template/apps/worker/.env.example +4 -0
- package/template/apps/worker/AGENTS.md +11 -0
- package/template/apps/worker/Dockerfile +35 -0
- package/template/apps/worker/knip.config.ts +3 -0
- package/template/apps/worker/kustomize/base/application.properties +4 -0
- package/template/apps/worker/kustomize/base/deployment.yaml +62 -0
- package/template/apps/worker/kustomize/base/kustomization.yaml +16 -0
- package/template/apps/worker/kustomize/overlays/prod/kustomization.yaml +12 -0
- package/template/apps/worker/kustomize/overlays/staging/application.properties +1 -0
- package/template/apps/worker/kustomize/overlays/staging/kustomization.yaml +18 -0
- package/template/apps/worker/oxfmt.config.ts +8 -0
- package/template/apps/worker/oxlint.config.ts +15 -0
- package/template/apps/worker/package.json +33 -0
- package/template/apps/worker/sonar-project.properties +8 -0
- package/template/apps/worker/src/env.ts +13 -0
- package/template/apps/worker/src/lib/graceful-shutdown.ts +27 -0
- package/template/apps/worker/src/lib/logger.ts +20 -0
- package/template/apps/worker/src/main.ts +17 -0
- package/template/apps/worker/src/processors/example-processor.ts +15 -0
- package/template/apps/worker/src/worker/redis.ts +19 -0
- package/template/apps/worker/src/worker/worker.ts +37 -0
- package/template/apps/worker/tests/example-processor.test.ts +13 -0
- package/template/apps/worker/tests/graceful-shutdown.test.ts +34 -0
- package/template/apps/worker/tsconfig.json +4 -0
- package/template/apps/worker/vitest.config.ts +13 -0
- package/template/bun.lock +3053 -0
- package/template/bunfig.toml +2 -0
- package/template/compose.yaml +53 -0
- package/template/docker-bake.hcl +86 -0
- package/template/package.json +47 -0
- package/template/packages/lint/knip.ts +40 -0
- package/template/packages/lint/oxfmt.ts +8 -0
- package/template/packages/lint/oxlint.ts +136 -0
- package/template/packages/lint/package.json +13 -0
- package/template/packages/queues/oxfmt.config.ts +8 -0
- package/template/packages/queues/oxlint.config.ts +7 -0
- package/template/packages/queues/package.json +15 -0
- package/template/packages/queues/src/example-job.ts +11 -0
- package/template/packages/queues/src/index.ts +3 -0
- package/template/packages/queues/src/queue-names.ts +2 -0
- package/template/packages/queues/src/redis-connection.ts +27 -0
- package/template/packages/queues/tsconfig.json +4 -0
- package/template/packages/tsconfig/base.json +23 -0
- package/template/packages/tsconfig/node.json +11 -0
- package/template/packages/tsconfig/package.json +4 -0
- package/template/turbo.json +50 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
|
|
2
|
+
import { Ban, Building2, Users } from "lucide-react";
|
|
3
|
+
import { expect, userEvent, within } from "storybook/test";
|
|
4
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs";
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof Tabs> = {
|
|
7
|
+
component: Tabs,
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: "centered",
|
|
10
|
+
},
|
|
11
|
+
tags: ["autodocs"],
|
|
12
|
+
} satisfies Meta<typeof Tabs>;
|
|
13
|
+
|
|
14
|
+
export default meta;
|
|
15
|
+
type Story = StoryObj<typeof meta>;
|
|
16
|
+
|
|
17
|
+
export const Default: Story = {
|
|
18
|
+
play: async ({ canvasElement }) => {
|
|
19
|
+
const canvas = within(canvasElement);
|
|
20
|
+
|
|
21
|
+
await expect(canvas.getByText("12 total tasks")).toBeInTheDocument();
|
|
22
|
+
|
|
23
|
+
await userEvent.click(canvas.getByRole("tab", { name: "Active" }));
|
|
24
|
+
await expect(canvas.getByText("8 active tasks")).toBeInTheDocument();
|
|
25
|
+
},
|
|
26
|
+
render: () => (
|
|
27
|
+
<Tabs className="w-[400px]" defaultValue="all">
|
|
28
|
+
<TabsList className="grid w-full grid-cols-3">
|
|
29
|
+
<TabsTrigger value="all">All</TabsTrigger>
|
|
30
|
+
<TabsTrigger value="active">Active</TabsTrigger>
|
|
31
|
+
<TabsTrigger value="completed">Completed</TabsTrigger>
|
|
32
|
+
</TabsList>
|
|
33
|
+
<TabsContent className="mt-4 space-y-2" value="all">
|
|
34
|
+
<div className="border-border bg-card rounded-lg border p-4">
|
|
35
|
+
<p className="text-sm">12 total tasks</p>
|
|
36
|
+
</div>
|
|
37
|
+
</TabsContent>
|
|
38
|
+
<TabsContent className="mt-4 space-y-2" value="active">
|
|
39
|
+
<div className="border-border bg-card rounded-lg border p-4">
|
|
40
|
+
<p className="text-sm">8 active tasks</p>
|
|
41
|
+
</div>
|
|
42
|
+
</TabsContent>
|
|
43
|
+
<TabsContent className="mt-4 space-y-2" value="completed">
|
|
44
|
+
<div className="border-border bg-card rounded-lg border p-4">
|
|
45
|
+
<p className="text-sm">4 completed tasks</p>
|
|
46
|
+
</div>
|
|
47
|
+
</TabsContent>
|
|
48
|
+
</Tabs>
|
|
49
|
+
),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const WithIcons: Story = {
|
|
53
|
+
render: () => (
|
|
54
|
+
<Tabs className="w-[350px]" defaultValue="friends">
|
|
55
|
+
<TabsList className="grid w-full grid-cols-3">
|
|
56
|
+
<TabsTrigger value="friends">
|
|
57
|
+
<Users className="mr-2 h-4 w-4" />
|
|
58
|
+
Friends
|
|
59
|
+
</TabsTrigger>
|
|
60
|
+
<TabsTrigger value="institution">
|
|
61
|
+
<Building2 className="mr-2 h-4 w-4" />
|
|
62
|
+
Institution
|
|
63
|
+
</TabsTrigger>
|
|
64
|
+
<TabsTrigger value="blocked">
|
|
65
|
+
<Ban className="mr-2 h-4 w-4" />
|
|
66
|
+
Blocked
|
|
67
|
+
</TabsTrigger>
|
|
68
|
+
</TabsList>
|
|
69
|
+
<TabsContent className="mt-4" value="friends">
|
|
70
|
+
<div className="border-border bg-card rounded-lg border p-4">
|
|
71
|
+
<p className="text-sm font-medium">23 Friends</p>
|
|
72
|
+
<p className="text-muted-foreground mt-1 text-xs">People you follow</p>
|
|
73
|
+
</div>
|
|
74
|
+
</TabsContent>
|
|
75
|
+
<TabsContent className="mt-4" value="institution">
|
|
76
|
+
<div className="border-border bg-card rounded-lg border p-4">
|
|
77
|
+
<p className="text-sm font-medium">156 Members</p>
|
|
78
|
+
<p className="text-muted-foreground mt-1 text-xs">From your institution</p>
|
|
79
|
+
</div>
|
|
80
|
+
</TabsContent>
|
|
81
|
+
<TabsContent className="mt-4" value="blocked">
|
|
82
|
+
<div className="border-border bg-card rounded-lg border p-4">
|
|
83
|
+
<p className="text-sm font-medium">2 Blocked</p>
|
|
84
|
+
<p className="text-muted-foreground mt-1 text-xs">Users you've blocked</p>
|
|
85
|
+
</div>
|
|
86
|
+
</TabsContent>
|
|
87
|
+
</Tabs>
|
|
88
|
+
),
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const IconOnly: Story = {
|
|
92
|
+
render: () => (
|
|
93
|
+
<Tabs className="w-[250px]" defaultValue="friends">
|
|
94
|
+
<TabsList className="grid h-8 w-full grid-cols-3 rounded-full p-0">
|
|
95
|
+
<TabsTrigger
|
|
96
|
+
aria-label="Friends"
|
|
97
|
+
className="data-[state=active]:bg-brand-500 data-[state=inactive]:hover:bg-brand-500/10 h-8 rounded-full data-[state=active]:text-white data-[state=active]:shadow-none"
|
|
98
|
+
value="friends"
|
|
99
|
+
>
|
|
100
|
+
<Users className="h-4 w-4" />
|
|
101
|
+
</TabsTrigger>
|
|
102
|
+
<TabsTrigger
|
|
103
|
+
aria-label="Institution"
|
|
104
|
+
className="data-[state=active]:bg-brand-500 data-[state=inactive]:hover:bg-brand-500/10 h-8 rounded-full data-[state=active]:text-white data-[state=active]:shadow-none"
|
|
105
|
+
value="institution"
|
|
106
|
+
>
|
|
107
|
+
<Building2 className="h-4 w-4" />
|
|
108
|
+
</TabsTrigger>
|
|
109
|
+
<TabsTrigger
|
|
110
|
+
aria-label="Blocked"
|
|
111
|
+
className="h-8 rounded-full data-[state=active]:bg-red-500 data-[state=active]:text-white data-[state=active]:shadow-none data-[state=inactive]:hover:bg-red-500/10"
|
|
112
|
+
value="blocked"
|
|
113
|
+
>
|
|
114
|
+
<Ban className="h-4 w-4" />
|
|
115
|
+
</TabsTrigger>
|
|
116
|
+
</TabsList>
|
|
117
|
+
<TabsContent value="friends">
|
|
118
|
+
<p className="text-muted-foreground text-sm">23 friends</p>
|
|
119
|
+
</TabsContent>
|
|
120
|
+
<TabsContent value="institution">
|
|
121
|
+
<p className="text-muted-foreground text-sm">156 members</p>
|
|
122
|
+
</TabsContent>
|
|
123
|
+
<TabsContent value="blocked">
|
|
124
|
+
<p className="text-muted-foreground text-sm">2 blocked</p>
|
|
125
|
+
</TabsContent>
|
|
126
|
+
</Tabs>
|
|
127
|
+
),
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const CustomColors: Story = {
|
|
131
|
+
render: () => (
|
|
132
|
+
<Tabs className="w-[400px]" defaultValue="success">
|
|
133
|
+
<TabsList className="grid w-full grid-cols-3">
|
|
134
|
+
<TabsTrigger
|
|
135
|
+
className="data-[state=active]:bg-green-700 data-[state=active]:text-white"
|
|
136
|
+
value="success"
|
|
137
|
+
>
|
|
138
|
+
Success
|
|
139
|
+
</TabsTrigger>
|
|
140
|
+
<TabsTrigger
|
|
141
|
+
className="data-[state=active]:bg-amber-700 data-[state=active]:text-white"
|
|
142
|
+
value="warning"
|
|
143
|
+
>
|
|
144
|
+
Warning
|
|
145
|
+
</TabsTrigger>
|
|
146
|
+
<TabsTrigger
|
|
147
|
+
className="data-[state=active]:bg-red-700 data-[state=active]:text-white"
|
|
148
|
+
value="error"
|
|
149
|
+
>
|
|
150
|
+
Error
|
|
151
|
+
</TabsTrigger>
|
|
152
|
+
</TabsList>
|
|
153
|
+
<TabsContent className="mt-4" value="success">
|
|
154
|
+
<div className="rounded-lg border border-green-500/20 bg-green-500/10 p-4">
|
|
155
|
+
<p className="text-sm font-medium text-green-800 dark:text-green-400">
|
|
156
|
+
Operation successful
|
|
157
|
+
</p>
|
|
158
|
+
<p className="mt-1 text-xs text-green-800 dark:text-green-500">All systems operational</p>
|
|
159
|
+
</div>
|
|
160
|
+
</TabsContent>
|
|
161
|
+
<TabsContent className="mt-4" value="warning">
|
|
162
|
+
<div className="rounded-lg border border-amber-500/20 bg-amber-500/10 p-4">
|
|
163
|
+
<p className="text-sm font-medium text-amber-800 dark:text-amber-400">Warning detected</p>
|
|
164
|
+
<p className="mt-1 text-xs text-amber-800 dark:text-amber-500">
|
|
165
|
+
Some issues need attention
|
|
166
|
+
</p>
|
|
167
|
+
</div>
|
|
168
|
+
</TabsContent>
|
|
169
|
+
<TabsContent className="mt-4" value="error">
|
|
170
|
+
<div className="rounded-lg border border-red-500/20 bg-red-500/10 p-4">
|
|
171
|
+
<p className="text-sm font-medium text-red-800 dark:text-red-400">Error occurred</p>
|
|
172
|
+
<p className="mt-1 text-xs text-red-800 dark:text-red-500">Action required</p>
|
|
173
|
+
</div>
|
|
174
|
+
</TabsContent>
|
|
175
|
+
</Tabs>
|
|
176
|
+
),
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export const WithDisabledTab: Story = {
|
|
180
|
+
render: () => (
|
|
181
|
+
<Tabs className="w-[400px]" defaultValue="profile">
|
|
182
|
+
<TabsList className="grid w-full grid-cols-3">
|
|
183
|
+
<TabsTrigger value="profile">Profile</TabsTrigger>
|
|
184
|
+
<TabsTrigger value="settings">Settings</TabsTrigger>
|
|
185
|
+
<TabsTrigger disabled value="billing">
|
|
186
|
+
Billing
|
|
187
|
+
</TabsTrigger>
|
|
188
|
+
</TabsList>
|
|
189
|
+
<TabsContent className="mt-4" value="profile">
|
|
190
|
+
<div className="border-border bg-card rounded-lg border p-4">
|
|
191
|
+
<p className="text-sm font-medium">Your Profile</p>
|
|
192
|
+
<p className="text-muted-foreground mt-1 text-xs">Manage your personal information</p>
|
|
193
|
+
</div>
|
|
194
|
+
</TabsContent>
|
|
195
|
+
<TabsContent className="mt-4" value="settings">
|
|
196
|
+
<div className="border-border bg-card rounded-lg border p-4">
|
|
197
|
+
<p className="text-sm font-medium">Settings</p>
|
|
198
|
+
<p className="text-muted-foreground mt-1 text-xs">Configure your preferences</p>
|
|
199
|
+
</div>
|
|
200
|
+
</TabsContent>
|
|
201
|
+
<TabsContent className="mt-4" value="billing">
|
|
202
|
+
<div className="border-border bg-card rounded-lg border p-4">
|
|
203
|
+
<p className="text-sm font-medium">Billing</p>
|
|
204
|
+
<p className="text-muted-foreground mt-1 text-xs">Upgrade to access billing</p>
|
|
205
|
+
</div>
|
|
206
|
+
</TabsContent>
|
|
207
|
+
</Tabs>
|
|
208
|
+
),
|
|
209
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
const Tabs = TabsPrimitive.Root;
|
|
8
|
+
|
|
9
|
+
function TabsList({
|
|
10
|
+
className,
|
|
11
|
+
ref,
|
|
12
|
+
...props
|
|
13
|
+
}: React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & {
|
|
14
|
+
ref?: React.RefObject<null | React.ComponentRef<typeof TabsPrimitive.List>>;
|
|
15
|
+
}) {
|
|
16
|
+
return (
|
|
17
|
+
<TabsPrimitive.List
|
|
18
|
+
className={cn(
|
|
19
|
+
"flex items-center justify-center rounded-full bg-muted/70 p-1 text-muted-foreground",
|
|
20
|
+
className,
|
|
21
|
+
)}
|
|
22
|
+
ref={ref}
|
|
23
|
+
{...props}
|
|
24
|
+
/>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
TabsList.displayName = TabsPrimitive.List.displayName;
|
|
28
|
+
|
|
29
|
+
function TabsTrigger({
|
|
30
|
+
className,
|
|
31
|
+
ref,
|
|
32
|
+
...props
|
|
33
|
+
}: React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> & {
|
|
34
|
+
ref?: React.RefObject<null | React.ComponentRef<typeof TabsPrimitive.Trigger>>;
|
|
35
|
+
}) {
|
|
36
|
+
return (
|
|
37
|
+
<TabsPrimitive.Trigger
|
|
38
|
+
className={cn(
|
|
39
|
+
"inline-flex cursor-pointer items-center justify-center whitespace-nowrap rounded-full px-3.5 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=active]:shadow-sm data-[state=inactive]:hover:bg-card",
|
|
40
|
+
className,
|
|
41
|
+
)}
|
|
42
|
+
ref={ref}
|
|
43
|
+
{...props}
|
|
44
|
+
/>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
|
48
|
+
|
|
49
|
+
function TabsContent({
|
|
50
|
+
className,
|
|
51
|
+
ref,
|
|
52
|
+
...props
|
|
53
|
+
}: React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> & {
|
|
54
|
+
ref?: React.RefObject<null | React.ComponentRef<typeof TabsPrimitive.Content>>;
|
|
55
|
+
}) {
|
|
56
|
+
return (
|
|
57
|
+
<TabsPrimitive.Content
|
|
58
|
+
className={cn(
|
|
59
|
+
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
60
|
+
className,
|
|
61
|
+
)}
|
|
62
|
+
ref={ref}
|
|
63
|
+
{...props}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
|
68
|
+
|
|
69
|
+
export { Tabs, TabsContent, TabsList, TabsTrigger };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
|
|
2
|
+
import { expect, userEvent, within } from "storybook/test";
|
|
3
|
+
import { TabsContent, TabsList, TabsTrigger } from "./tabs";
|
|
4
|
+
import { UrlTabs } from "./url-tabs";
|
|
5
|
+
|
|
6
|
+
const TABS = ["overview", "activity", "settings"] as const;
|
|
7
|
+
|
|
8
|
+
function UrlTabsExample() {
|
|
9
|
+
return (
|
|
10
|
+
<UrlTabs className="w-96" defaultValue="overview" tabs={TABS}>
|
|
11
|
+
<TabsList className="w-full">
|
|
12
|
+
{TABS.map((tab) => (
|
|
13
|
+
<TabsTrigger className="flex-1 capitalize" key={tab} value={tab}>
|
|
14
|
+
{tab}
|
|
15
|
+
</TabsTrigger>
|
|
16
|
+
))}
|
|
17
|
+
</TabsList>
|
|
18
|
+
{TABS.map((tab) => (
|
|
19
|
+
<TabsContent className="text-muted-foreground p-4 text-sm capitalize" key={tab} value={tab}>
|
|
20
|
+
{tab} panel
|
|
21
|
+
</TabsContent>
|
|
22
|
+
))}
|
|
23
|
+
</UrlTabs>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `Tabs` that mirror the active tab into `?tab=` so it survives reload and is
|
|
29
|
+
* shareable. The default tab keeps the URL clean.
|
|
30
|
+
*/
|
|
31
|
+
const meta = {
|
|
32
|
+
component: UrlTabsExample,
|
|
33
|
+
parameters: {
|
|
34
|
+
layout: "centered",
|
|
35
|
+
nextjs: { navigation: { pathname: "/institutions/kit", query: {} } },
|
|
36
|
+
},
|
|
37
|
+
tags: ["autodocs"],
|
|
38
|
+
} satisfies Meta<typeof UrlTabsExample>;
|
|
39
|
+
|
|
40
|
+
export default meta;
|
|
41
|
+
|
|
42
|
+
type Story = StoryObj<typeof meta>;
|
|
43
|
+
|
|
44
|
+
export const Default: Story = {
|
|
45
|
+
play: async ({ canvasElement }) => {
|
|
46
|
+
const canvas = within(canvasElement);
|
|
47
|
+
|
|
48
|
+
await expect(canvas.getAllByRole("tab")).toHaveLength(3);
|
|
49
|
+
await expect(canvas.getByRole("tab", { name: "overview" })).toHaveAttribute(
|
|
50
|
+
"data-state",
|
|
51
|
+
"active",
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
await userEvent.click(canvas.getByRole("tab", { name: "activity" }));
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const DeepLinkedTab: Story = {
|
|
59
|
+
parameters: {
|
|
60
|
+
nextjs: { navigation: { pathname: "/institutions/kit", query: { tab: "settings" } } },
|
|
61
|
+
},
|
|
62
|
+
play: async ({ canvasElement }) => {
|
|
63
|
+
await expect(within(canvasElement).getByRole("tab", { name: "settings" })).toHaveAttribute(
|
|
64
|
+
"data-state",
|
|
65
|
+
"active",
|
|
66
|
+
);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
4
|
+
import type { ComponentPropsWithoutRef } from "react";
|
|
5
|
+
import { Tabs } from "./tabs";
|
|
6
|
+
|
|
7
|
+
type Props = Omit<ComponentPropsWithoutRef<typeof Tabs>, "onValueChange" | "value"> & {
|
|
8
|
+
defaultValue: string;
|
|
9
|
+
paramName?: string;
|
|
10
|
+
tabs: readonly string[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Drop-in replacement for `Tabs` that mirrors the active tab into the URL
|
|
15
|
+
* (`?tab=...`) so switching tabs is shareable/back-button-able and survives a
|
|
16
|
+
* reload, while keeping the default tab's URL clean.
|
|
17
|
+
*/
|
|
18
|
+
export function UrlTabs({ defaultValue, paramName = "tab", tabs, ...props }: Props) {
|
|
19
|
+
const router = useRouter();
|
|
20
|
+
const pathname = usePathname();
|
|
21
|
+
const searchParams = useSearchParams();
|
|
22
|
+
|
|
23
|
+
const tabParam = searchParams.get(paramName);
|
|
24
|
+
const value = tabParam && tabs.includes(tabParam) ? tabParam : defaultValue;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Tabs
|
|
28
|
+
{...props}
|
|
29
|
+
onValueChange={(next) => {
|
|
30
|
+
const params = new URLSearchParams(searchParams);
|
|
31
|
+
if (next === defaultValue) {
|
|
32
|
+
params.delete(paramName);
|
|
33
|
+
} else {
|
|
34
|
+
params.set(paramName, next);
|
|
35
|
+
}
|
|
36
|
+
const query = params.toString();
|
|
37
|
+
router.replace(`${pathname}${query ? `?${query}` : ""}`, { scroll: false });
|
|
38
|
+
}}
|
|
39
|
+
value={value}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { expect, userEvent, within } from "storybook/test";
|
|
4
|
+
import { Textarea } from "./textarea";
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof Textarea> = {
|
|
7
|
+
component: Textarea,
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: "centered",
|
|
10
|
+
},
|
|
11
|
+
tags: ["autodocs"],
|
|
12
|
+
} satisfies Meta<typeof Textarea>;
|
|
13
|
+
|
|
14
|
+
export default meta;
|
|
15
|
+
type Story = StoryObj<typeof meta>;
|
|
16
|
+
|
|
17
|
+
export const Default: Story = {
|
|
18
|
+
play: async ({ canvasElement }) => {
|
|
19
|
+
const field = within(canvasElement).getByPlaceholderText("Type your message here...");
|
|
20
|
+
|
|
21
|
+
await userEvent.type(field, "Hello there");
|
|
22
|
+
await expect(field).toHaveValue("Hello there");
|
|
23
|
+
},
|
|
24
|
+
render: () => <Textarea className="w-[400px]" placeholder="Type your message here..." />,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const WithLabel: Story = {
|
|
28
|
+
render: () => (
|
|
29
|
+
<Textarea className="w-[400px]" label="Message" placeholder="Type your message here..." />
|
|
30
|
+
),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const WithHint: Story = {
|
|
34
|
+
render: () => (
|
|
35
|
+
<Textarea
|
|
36
|
+
className="w-[400px]"
|
|
37
|
+
hint="This will be displayed on your public profile."
|
|
38
|
+
label="Bio"
|
|
39
|
+
placeholder="Tell us about yourself..."
|
|
40
|
+
/>
|
|
41
|
+
),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const WithError: Story = {
|
|
45
|
+
render: () => (
|
|
46
|
+
<Textarea
|
|
47
|
+
className="w-[400px]"
|
|
48
|
+
error="Description must be at least 10 characters long."
|
|
49
|
+
label="Description"
|
|
50
|
+
placeholder="Enter a description..."
|
|
51
|
+
/>
|
|
52
|
+
),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const Success: Story = {
|
|
56
|
+
render: () => (
|
|
57
|
+
<Textarea
|
|
58
|
+
className="w-[400px]"
|
|
59
|
+
defaultValue="This product exceeded my expectations! The quality is outstanding."
|
|
60
|
+
hint="Your review looks great!"
|
|
61
|
+
isSuccess
|
|
62
|
+
label="Review"
|
|
63
|
+
placeholder="Write your review..."
|
|
64
|
+
/>
|
|
65
|
+
),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const Disabled: Story = {
|
|
69
|
+
render: () => (
|
|
70
|
+
<Textarea
|
|
71
|
+
className="w-[400px]"
|
|
72
|
+
defaultValue="This textarea is disabled and cannot be edited."
|
|
73
|
+
disabled
|
|
74
|
+
label="Readonly Message"
|
|
75
|
+
placeholder="Type your message here..."
|
|
76
|
+
/>
|
|
77
|
+
),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const SmallSize: Story = {
|
|
81
|
+
render: () => (
|
|
82
|
+
<Textarea
|
|
83
|
+
className="w-[400px]"
|
|
84
|
+
label="Quick Note"
|
|
85
|
+
placeholder="Add a quick note..."
|
|
86
|
+
size="sm"
|
|
87
|
+
/>
|
|
88
|
+
),
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const WithRows: Story = {
|
|
92
|
+
render: () => (
|
|
93
|
+
<Textarea
|
|
94
|
+
className="w-[500px]"
|
|
95
|
+
hint="Minimum 500 words required."
|
|
96
|
+
label="Essay"
|
|
97
|
+
placeholder="Write your essay here..."
|
|
98
|
+
rows={10}
|
|
99
|
+
/>
|
|
100
|
+
),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const WithCharacterCount: Story = {
|
|
104
|
+
render: () => {
|
|
105
|
+
const [value, setValue] = useState("");
|
|
106
|
+
const maxLength = 200;
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<Textarea
|
|
110
|
+
className="w-[400px]"
|
|
111
|
+
hint={`${value.length}/${maxLength} characters`}
|
|
112
|
+
label="Tweet"
|
|
113
|
+
maxLength={maxLength}
|
|
114
|
+
onChange={(e) => setValue(e.target.value)}
|
|
115
|
+
placeholder="What's happening?"
|
|
116
|
+
value={value}
|
|
117
|
+
/>
|
|
118
|
+
);
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const InForm: Story = {
|
|
123
|
+
render: () => (
|
|
124
|
+
<div className="w-[500px] space-y-6">
|
|
125
|
+
<div className="space-y-4 rounded-2xl border border-white/20 bg-white/10 p-6 backdrop-blur-sm">
|
|
126
|
+
<h3 className="text-lg font-semibold">Contact Form</h3>
|
|
127
|
+
|
|
128
|
+
<Textarea
|
|
129
|
+
hint="Please provide as much detail as possible."
|
|
130
|
+
label="Message"
|
|
131
|
+
placeholder="How can we help you?"
|
|
132
|
+
rows={4}
|
|
133
|
+
/>
|
|
134
|
+
|
|
135
|
+
<Textarea
|
|
136
|
+
label="Additional Notes"
|
|
137
|
+
placeholder="Any additional information..."
|
|
138
|
+
rows={3}
|
|
139
|
+
size="sm"
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
),
|
|
144
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { TextareaHTMLAttributes } from "react";
|
|
4
|
+
import { useId } from "react";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
type TextareaProps = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "ref"> & {
|
|
8
|
+
containerClassName?: string;
|
|
9
|
+
error?: string;
|
|
10
|
+
frameClassName?: string;
|
|
11
|
+
hint?: string;
|
|
12
|
+
isSuccess?: boolean;
|
|
13
|
+
label?: string;
|
|
14
|
+
ref?: React.Ref<HTMLTextAreaElement>;
|
|
15
|
+
size?: "md" | "sm";
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const FRAME_BASE =
|
|
19
|
+
"flex w-full rounded-xl border bg-card transition-[border-color,box-shadow] duration-150";
|
|
20
|
+
const FRAME_SIZE = { md: "min-h-[120px] px-3.5 py-2.5", sm: "min-h-[80px] px-3 py-2" } as const;
|
|
21
|
+
const TEXTAREA_BASE =
|
|
22
|
+
"w-full min-w-0 resize-none bg-transparent text-foreground outline-none placeholder:text-muted-foreground/60 focus-visible:outline-none";
|
|
23
|
+
const TEXTAREA_SIZE = { md: "text-base", sm: "text-sm" } as const;
|
|
24
|
+
|
|
25
|
+
const FRAME_STATE = {
|
|
26
|
+
default:
|
|
27
|
+
"border-input shadow-xs focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/25",
|
|
28
|
+
disabled: "cursor-not-allowed border-input bg-muted/50 shadow-none dark:bg-muted/30",
|
|
29
|
+
error:
|
|
30
|
+
"border-destructive/60 shadow-xs focus-within:border-destructive focus-within:ring-2 focus-within:ring-destructive/20",
|
|
31
|
+
success:
|
|
32
|
+
"border-emerald-500/50 shadow-xs focus-within:border-emerald-500 focus-within:ring-2 focus-within:ring-emerald-500/20",
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
function Textarea(props: TextareaProps) {
|
|
36
|
+
const {
|
|
37
|
+
className,
|
|
38
|
+
containerClassName,
|
|
39
|
+
disabled,
|
|
40
|
+
error,
|
|
41
|
+
frameClassName,
|
|
42
|
+
hint,
|
|
43
|
+
id,
|
|
44
|
+
isSuccess,
|
|
45
|
+
label,
|
|
46
|
+
ref,
|
|
47
|
+
size = "md",
|
|
48
|
+
...textareaProps
|
|
49
|
+
} = props;
|
|
50
|
+
|
|
51
|
+
const generatedId = useId();
|
|
52
|
+
|
|
53
|
+
const textareaId = id ?? `${generatedId}-textarea`;
|
|
54
|
+
const hasError = Boolean(error);
|
|
55
|
+
const hasSuccess = isSuccess && !hasError;
|
|
56
|
+
|
|
57
|
+
const describedBy =
|
|
58
|
+
[
|
|
59
|
+
hasError && `${textareaId}-error`,
|
|
60
|
+
hasSuccess && `${textareaId}-success`,
|
|
61
|
+
hint && !hasError && `${textareaId}-hint`,
|
|
62
|
+
]
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
.join(" ") || undefined;
|
|
65
|
+
|
|
66
|
+
const state = disabled ? "disabled" : hasError ? "error" : hasSuccess ? "success" : "default";
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<fieldset
|
|
70
|
+
className={cn(
|
|
71
|
+
"text-foreground/90 flex flex-col gap-y-1.5 text-sm",
|
|
72
|
+
disabled && "opacity-70",
|
|
73
|
+
containerClassName,
|
|
74
|
+
)}
|
|
75
|
+
>
|
|
76
|
+
{label && (
|
|
77
|
+
<label className="ml-0.5 text-sm font-medium" htmlFor={textareaId}>
|
|
78
|
+
{label}
|
|
79
|
+
</label>
|
|
80
|
+
)}
|
|
81
|
+
|
|
82
|
+
<div className={cn(FRAME_BASE, FRAME_SIZE[size], FRAME_STATE[state], frameClassName)}>
|
|
83
|
+
<textarea
|
|
84
|
+
{...textareaProps}
|
|
85
|
+
aria-describedby={describedBy}
|
|
86
|
+
aria-invalid={hasError}
|
|
87
|
+
className={cn(
|
|
88
|
+
TEXTAREA_BASE,
|
|
89
|
+
TEXTAREA_SIZE[size],
|
|
90
|
+
disabled && "cursor-not-allowed",
|
|
91
|
+
className,
|
|
92
|
+
)}
|
|
93
|
+
disabled={disabled}
|
|
94
|
+
id={textareaId}
|
|
95
|
+
ref={ref}
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
{hasError && error && (
|
|
100
|
+
<p className="text-destructive ml-0.5 text-xs font-medium" id={`${textareaId}-error`}>
|
|
101
|
+
{error}
|
|
102
|
+
</p>
|
|
103
|
+
)}
|
|
104
|
+
{!hasError && hint && (
|
|
105
|
+
<p className="text-muted-foreground ml-0.5 text-xs" id={`${textareaId}-hint`}>
|
|
106
|
+
{hint}
|
|
107
|
+
</p>
|
|
108
|
+
)}
|
|
109
|
+
</fieldset>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
Textarea.displayName = "Textarea";
|
|
114
|
+
|
|
115
|
+
export { Textarea };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Toaster as Sonner } from "sonner";
|
|
4
|
+
|
|
5
|
+
export function Toaster() {
|
|
6
|
+
return (
|
|
7
|
+
<Sonner
|
|
8
|
+
closeButton
|
|
9
|
+
expand={false}
|
|
10
|
+
gap={10}
|
|
11
|
+
position="bottom-right"
|
|
12
|
+
toastOptions={{
|
|
13
|
+
classNames: {
|
|
14
|
+
actionButton:
|
|
15
|
+
"!rounded-md !bg-foreground !px-2.5 !py-1 !text-[11px] !font-medium !text-background transition-colors hover:!bg-foreground/90",
|
|
16
|
+
cancelButton:
|
|
17
|
+
"!rounded-md !bg-muted !px-2.5 !py-1 !text-[11px] !font-medium !text-muted-foreground transition-colors hover:!bg-muted/80",
|
|
18
|
+
closeButton:
|
|
19
|
+
"!absolute !left-auto !right-2.5 !top-2.5 !rounded-md !border-0 !bg-transparent !p-0.5 !text-foreground/40 !opacity-0 !shadow-none transition-all hover:!text-foreground focus:!opacity-100 focus:!outline-none focus-visible:!ring-2 focus-visible:!ring-ring group-hover:!opacity-100 [&_svg]:h-3.5 [&_svg]:w-3.5",
|
|
20
|
+
content: "flex flex-col gap-0.5",
|
|
21
|
+
description: "text-[12px] leading-snug text-muted-foreground",
|
|
22
|
+
error:
|
|
23
|
+
"!border-l-red-500 [&_svg]:text-red-500 dark:!border-l-red-400 dark:[&_svg]:text-red-400",
|
|
24
|
+
icon: "flex h-[18px] w-[18px] shrink-0 items-center justify-center [&_svg]:h-[18px] [&_svg]:w-[18px]",
|
|
25
|
+
info: "!border-l-blue-500 [&_svg]:text-blue-500 dark:!border-l-blue-400 dark:[&_svg]:text-blue-400",
|
|
26
|
+
success:
|
|
27
|
+
"!border-l-emerald-500 [&_svg]:text-emerald-500 dark:!border-l-emerald-400 dark:[&_svg]:text-emerald-400",
|
|
28
|
+
title: "text-[13px] font-medium leading-tight text-foreground",
|
|
29
|
+
toast:
|
|
30
|
+
"group relative flex w-full min-w-[300px] max-w-[380px] items-center gap-3 overflow-hidden rounded-2xl border border-border/70 border-l-[3px] bg-popover py-3 pl-4 pr-9 shadow-[0_12px_32px_-12px_rgba(0,0,0,0.22)] transition-all",
|
|
31
|
+
warning:
|
|
32
|
+
"!border-l-amber-500 [&_svg]:text-amber-500 dark:!border-l-amber-400 dark:[&_svg]:text-amber-400",
|
|
33
|
+
},
|
|
34
|
+
unstyled: true,
|
|
35
|
+
}}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
}
|