@mahiraltinkaya/me-ui 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/LICENSE +21 -0
- package/README.md +99 -0
- package/bin/me-ui.js +60 -0
- package/package.json +34 -0
- package/registry/assets/breadcrumb_arrow.svg +3 -0
- package/registry/components/stepper-provider.tsx +88 -0
- package/registry/components/stepper-view.tsx +40 -0
- package/registry/components/steps/form-input.tsx +68 -0
- package/registry/components/steps/form-select.tsx +42 -0
- package/registry/components/steps/step-field.tsx +43 -0
- package/registry/components/ui/button.tsx +69 -0
- package/registry/components/ui/field-hint.tsx +68 -0
- package/registry/components/ui/input.tsx +87 -0
- package/registry/components/ui/select.tsx +128 -0
- package/registry/components/ui/stepper/index.tsx +24 -0
- package/registry/components/ui/stepper/step-divider.tsx +20 -0
- package/registry/components/ui/stepper/step-indicator.tsx +33 -0
- package/registry/components/ui/stepper/step-label.tsx +29 -0
- package/registry/components/ui/stepper/stepper-progress.tsx +40 -0
- package/registry/components/ui/stepper/stepper-rail.tsx +107 -0
- package/registry/components/ui/stepper/types.ts +26 -0
- package/registry/components/ui/tooltip.tsx +54 -0
- package/registry/lib/normalize.ts +2 -0
- package/registry/lib/quote-schema.ts +45 -0
- package/registry/lib/tckn.ts +31 -0
- package/registry/lib/utils.ts +6 -0
- package/registry.json +252 -0
- package/src/args.js +47 -0
- package/src/commands/add.js +118 -0
- package/src/commands/list.js +20 -0
- package/src/css.js +80 -0
- package/src/deps.js +52 -0
- package/src/jsonc.js +44 -0
- package/src/log.js +32 -0
- package/src/manifest.js +58 -0
- package/src/paths.js +65 -0
- package/src/project.js +100 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mahir Altınkaya
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# me-ui
|
|
2
|
+
|
|
3
|
+
Copies the design system's components into your project. They land as your files —
|
|
4
|
+
edit them, restyle them, delete the parts you do not need. Nothing is imported from
|
|
5
|
+
`node_modules` at runtime, so there is no version to keep in step and no wrapper to
|
|
6
|
+
fight when a screen needs something slightly different.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npx @mahiraltinkaya/me-ui add input
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Commands
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npx @mahiraltinkaya/me-ui add <item…> # copy items and everything they depend on
|
|
16
|
+
npx @mahiraltinkaya/me-ui list # show every item
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
| Option | |
|
|
20
|
+
| ------------------ | ---------------------------------------------------- |
|
|
21
|
+
| `-c, --cwd <path>` | project to install into (default: current directory) |
|
|
22
|
+
| `-o, --overwrite` | replace files that already exist |
|
|
23
|
+
| `--dry-run` | show what would happen, write nothing |
|
|
24
|
+
| `--skip-install` | do not run the package manager |
|
|
25
|
+
|
|
26
|
+
Existing files are never replaced silently. A file you have edited is reported and
|
|
27
|
+
left alone until you pass `--overwrite`, so re-running `add` to pick up a dependency
|
|
28
|
+
cannot cost you local changes.
|
|
29
|
+
|
|
30
|
+
## Items
|
|
31
|
+
|
|
32
|
+
| Item | What it is |
|
|
33
|
+
| ------------------ | ------------------------------------------------------------------ |
|
|
34
|
+
| `utils` | `cn` — clsx piped through tailwind-merge. |
|
|
35
|
+
| `brand-theme` | The `--brand` token the fields paint focus and error states with. |
|
|
36
|
+
| `field-hint` | Focus-driven explanatory panel anchored to a field. |
|
|
37
|
+
| `input` | Text field with optional leading icon and hint. |
|
|
38
|
+
| `select` | Dropdown over a string list, matching `input`'s affordances. |
|
|
39
|
+
| `button` | Button with `cva` variant and size sets. |
|
|
40
|
+
| `tooltip` | Tooltip provider, root, trigger and content. |
|
|
41
|
+
| `stepper` | Progress rail for multi-step flows. **Requires Next.js.** |
|
|
42
|
+
| `stepper-provider` | Step state, next/back guards and the `useStepper` hook. |
|
|
43
|
+
| `step-field` | Label, control and validation message. |
|
|
44
|
+
| `form-fields` | `FormInput` and `FormSelect`, bound to react-hook-form. |
|
|
45
|
+
| `normalize` | `digits` — strips everything that is not a digit. |
|
|
46
|
+
| `tckn` | T.C. kimlik no validation, check digits included. |
|
|
47
|
+
| `quote-schema` | Zod schemas for the quote flow, plus per-step fields and defaults. |
|
|
48
|
+
|
|
49
|
+
Dependencies resolve on their own — `me-ui add form-fields` also brings `input`,
|
|
50
|
+
`select`, `step-field`, `cn`, the npm packages and the `--brand` token.
|
|
51
|
+
|
|
52
|
+
## Where files land
|
|
53
|
+
|
|
54
|
+
A `components.json` is honoured when the project has one, so files follow the
|
|
55
|
+
aliases the rest of its UI already uses. Otherwise the CLI reads `tsconfig.json`
|
|
56
|
+
paths, falling back to the `src/` convention:
|
|
57
|
+
|
|
58
|
+
| Registry path | Default destination |
|
|
59
|
+
| ----------------- | ------------------------ |
|
|
60
|
+
| `components/ui/…` | `src/components/ui/…` |
|
|
61
|
+
| `components/…` | `src/components/…` |
|
|
62
|
+
| `lib/…` | `src/lib/…` |
|
|
63
|
+
| `assets/…` | `public/assets/images/…` |
|
|
64
|
+
|
|
65
|
+
Imports inside the copied files are rewritten to match, whatever the aliases are.
|
|
66
|
+
|
|
67
|
+
## What the project needs first
|
|
68
|
+
|
|
69
|
+
- **React 19** and **Tailwind CSS v4**.
|
|
70
|
+
- **A shadcn base theme.** The components style against `--border`, `--input`,
|
|
71
|
+
`--muted-foreground` and friends. `add` warns about any that are missing —
|
|
72
|
+
`npx shadcn@latest init` is the quickest way to get them.
|
|
73
|
+
- **Next.js**, but only for `stepper`, which renders its divider with `next/image`.
|
|
74
|
+
|
|
75
|
+
## Publishing
|
|
76
|
+
|
|
77
|
+
Public package on npmjs.com — anyone can run `npx @mahiraltinkaya/me-ui` without an account or a
|
|
78
|
+
token.
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
bun run ui:sync # from the repo root, after changing any component
|
|
82
|
+
cd packages/me-ui
|
|
83
|
+
npm version patch # npm refuses to overwrite a published version
|
|
84
|
+
npm publish
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`prepublishOnly` re-checks that `registry/` still matches the app it was copied
|
|
88
|
+
from, so a stale component cannot ship.
|
|
89
|
+
|
|
90
|
+
Copied files do not update themselves — that is the point of copying rather than
|
|
91
|
+
importing. To take a newer version of a component:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
npx @mahiraltinkaya/me-ui@latest add input --overwrite
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`@latest` matters because npx will otherwise happily reuse a cached older build,
|
|
98
|
+
and `--overwrite` matters because `add` protects existing files by default. Run it
|
|
99
|
+
with `--dry-run` first if the file has local edits worth keeping.
|
package/bin/me-ui.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import { parseArgs } from "../src/args.js";
|
|
8
|
+
import { add } from "../src/commands/add.js";
|
|
9
|
+
import { list } from "../src/commands/list.js";
|
|
10
|
+
import { bold, cyan, dim, fail, line } from "../src/log.js";
|
|
11
|
+
|
|
12
|
+
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
13
|
+
const { version } = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
|
|
14
|
+
|
|
15
|
+
const HELP = `
|
|
16
|
+
${bold("me-ui")} ${dim(`v${version}`)}
|
|
17
|
+
|
|
18
|
+
Copies components into your project. They become your files — edit them freely.
|
|
19
|
+
|
|
20
|
+
${bold("Usage")}
|
|
21
|
+
${cyan("npx @mahiraltinkaya/me-ui add")} <item…> copy items and their dependencies
|
|
22
|
+
${cyan("npx @mahiraltinkaya/me-ui list")} show every item
|
|
23
|
+
|
|
24
|
+
${bold("Options")}
|
|
25
|
+
-c, --cwd <path> project to install into ${dim("(default: current directory)")}
|
|
26
|
+
-o, --overwrite replace files that already exist
|
|
27
|
+
--dry-run show what would happen, write nothing
|
|
28
|
+
--skip-install do not run the package manager
|
|
29
|
+
-h, --help this text
|
|
30
|
+
-v, --version print the version
|
|
31
|
+
|
|
32
|
+
${bold("Examples")}
|
|
33
|
+
${dim("$")} npx @mahiraltinkaya/me-ui add input
|
|
34
|
+
${dim("$")} npx @mahiraltinkaya/me-ui add form-fields quote-schema
|
|
35
|
+
${dim("$")} npx @mahiraltinkaya/me-ui add stepper --dry-run
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
function main() {
|
|
39
|
+
const options = parseArgs(process.argv.slice(2));
|
|
40
|
+
|
|
41
|
+
if (options.version) return line(version);
|
|
42
|
+
if (options.help || !options.command) return line(HELP);
|
|
43
|
+
|
|
44
|
+
switch (options.command) {
|
|
45
|
+
case "add":
|
|
46
|
+
return add(options);
|
|
47
|
+
case "list":
|
|
48
|
+
case "ls":
|
|
49
|
+
return list();
|
|
50
|
+
default:
|
|
51
|
+
throw new Error(`Unknown command "${options.command}". Try \`me-ui --help\`.`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
main();
|
|
57
|
+
} catch (error) {
|
|
58
|
+
fail(error.message);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mahiraltinkaya/me-ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Copy React + Tailwind form components into any project — they become your files, not a dependency.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"me-ui": "bin/me-ui.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"registry",
|
|
13
|
+
"registry.json"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20.11"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"prepublishOnly": "node ../../scripts/sync-registry.mjs --check"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"ui",
|
|
26
|
+
"components",
|
|
27
|
+
"react",
|
|
28
|
+
"tailwind",
|
|
29
|
+
"base-ui",
|
|
30
|
+
"shadcn",
|
|
31
|
+
"cli"
|
|
32
|
+
],
|
|
33
|
+
"license": "MIT"
|
|
34
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import type { StepItem } from "@/components/ui/stepper";
|
|
6
|
+
|
|
7
|
+
export interface StepperContextValue {
|
|
8
|
+
steps: StepItem[];
|
|
9
|
+
currentStep: number;
|
|
10
|
+
totalSteps: number;
|
|
11
|
+
isFirstStep: boolean;
|
|
12
|
+
isLastStep: boolean;
|
|
13
|
+
goNext: () => Promise<boolean>;
|
|
14
|
+
goBack: () => void;
|
|
15
|
+
goToStep: (index: number) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const StepperContext = React.createContext<StepperContextValue | null>(null);
|
|
19
|
+
|
|
20
|
+
export function useStepper() {
|
|
21
|
+
const context = React.useContext(StepperContext);
|
|
22
|
+
if (!context) {
|
|
23
|
+
throw new Error("useStepper must be used within a <StepperProvider>");
|
|
24
|
+
}
|
|
25
|
+
return context;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface StepperProviderProps {
|
|
29
|
+
steps: StepItem[];
|
|
30
|
+
initialStep?: number;
|
|
31
|
+
onStepChange?: (index: number) => void;
|
|
32
|
+
beforeNext?: (index: number) => boolean | Promise<boolean>;
|
|
33
|
+
children: React.ReactNode;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Step state and nothing else — no markup of its own.
|
|
38
|
+
*
|
|
39
|
+
* The rail and the active step are rendered by `<StepperView>`, which reads
|
|
40
|
+
* this context. Keeping them apart means a consumer can drive step state from
|
|
41
|
+
* a layout this component knows nothing about.
|
|
42
|
+
*/
|
|
43
|
+
function StepperProvider({
|
|
44
|
+
steps,
|
|
45
|
+
initialStep = 0,
|
|
46
|
+
onStepChange,
|
|
47
|
+
beforeNext,
|
|
48
|
+
children,
|
|
49
|
+
}: StepperProviderProps) {
|
|
50
|
+
const [currentStep, setCurrentStep] = React.useState(initialStep);
|
|
51
|
+
const totalSteps = steps.length;
|
|
52
|
+
|
|
53
|
+
const goToStep = React.useCallback(
|
|
54
|
+
(index: number) => {
|
|
55
|
+
const clamped = Math.min(Math.max(index, 0), totalSteps - 1);
|
|
56
|
+
setCurrentStep(clamped);
|
|
57
|
+
onStepChange?.(clamped);
|
|
58
|
+
},
|
|
59
|
+
[totalSteps, onStepChange],
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const goNext = React.useCallback(async () => {
|
|
63
|
+
const allowed = (await beforeNext?.(currentStep)) ?? true;
|
|
64
|
+
if (!allowed) return false;
|
|
65
|
+
goToStep(currentStep + 1);
|
|
66
|
+
return true;
|
|
67
|
+
}, [beforeNext, currentStep, goToStep]);
|
|
68
|
+
|
|
69
|
+
const goBack = React.useCallback(() => goToStep(currentStep - 1), [currentStep, goToStep]);
|
|
70
|
+
|
|
71
|
+
const value = React.useMemo<StepperContextValue>(
|
|
72
|
+
() => ({
|
|
73
|
+
steps,
|
|
74
|
+
currentStep,
|
|
75
|
+
totalSteps,
|
|
76
|
+
isFirstStep: currentStep === 0,
|
|
77
|
+
isLastStep: currentStep === totalSteps - 1,
|
|
78
|
+
goNext,
|
|
79
|
+
goBack,
|
|
80
|
+
goToStep,
|
|
81
|
+
}),
|
|
82
|
+
[steps, currentStep, totalSteps, goNext, goBack, goToStep],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
return <StepperContext.Provider value={value}>{children}</StepperContext.Provider>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export { StepperProvider };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { useStepper } from "@/components/stepper-provider";
|
|
6
|
+
import { Stepper } from "@/components/ui/stepper";
|
|
7
|
+
import { cn } from "@/lib/utils";
|
|
8
|
+
|
|
9
|
+
export interface StepperViewProps {
|
|
10
|
+
className?: string;
|
|
11
|
+
/** One child per step, in the same order as the provider's `steps`. */
|
|
12
|
+
children: React.ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Renders the progress rail and the child sitting at the active index.
|
|
17
|
+
*
|
|
18
|
+
* The live region is what tells a screen reader the step changed: the rail is
|
|
19
|
+
* `aria-hidden` decoration and swapping the panel underneath it is otherwise a
|
|
20
|
+
* silent DOM update.
|
|
21
|
+
*/
|
|
22
|
+
function StepperView({ className, children }: StepperViewProps) {
|
|
23
|
+
const { steps, currentStep, totalSteps, goToStep } = useStepper();
|
|
24
|
+
|
|
25
|
+
// Children only change when the caller re-renders with a different set, so
|
|
26
|
+
// the array is not rebuilt on every step change.
|
|
27
|
+
const stepChildren = React.useMemo(() => React.Children.toArray(children), [children]);
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className={cn("flex w-full flex-col gap-10", className)}>
|
|
31
|
+
<Stepper steps={steps} currentStep={currentStep} onStepChange={goToStep} />
|
|
32
|
+
<p aria-live="polite" className="sr-only">
|
|
33
|
+
{`Adım ${currentStep + 1} / ${totalSteps}: ${steps[currentStep]?.label ?? ""}`}
|
|
34
|
+
</p>
|
|
35
|
+
{stepChildren[currentStep]}
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export { StepperView };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, type ChangeEvent } from "react";
|
|
4
|
+
import { useController, type FieldValues, type Path } from "react-hook-form";
|
|
5
|
+
|
|
6
|
+
import { Input, type InputProps } from "@/components/ui/input";
|
|
7
|
+
import { StepField, errorIdFor } from "@/components/steps/step-field";
|
|
8
|
+
|
|
9
|
+
export interface FormInputProps<T extends FieldValues> extends Omit<
|
|
10
|
+
InputProps,
|
|
11
|
+
"name" | "defaultValue" | "value" | "onChange" | "onBlur"
|
|
12
|
+
> {
|
|
13
|
+
name: Path<T>;
|
|
14
|
+
label: string;
|
|
15
|
+
/**
|
|
16
|
+
* Applied to every keystroke and paste before the value reaches the form, so
|
|
17
|
+
* what is stored is already canonical. When set, `maxLength` counts
|
|
18
|
+
* normalized characters rather than typed ones.
|
|
19
|
+
*/
|
|
20
|
+
normalize?: (value: string) => string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Text field bound to the surrounding react-hook-form context.
|
|
25
|
+
*
|
|
26
|
+
* `useController` subscribes to this field alone, so a validation error on one
|
|
27
|
+
* field re-renders only that field — never its siblings or the step card.
|
|
28
|
+
*/
|
|
29
|
+
function FormInput<T extends FieldValues>({
|
|
30
|
+
name,
|
|
31
|
+
label,
|
|
32
|
+
normalize,
|
|
33
|
+
maxLength,
|
|
34
|
+
...props
|
|
35
|
+
}: FormInputProps<T>) {
|
|
36
|
+
const { field, fieldState } = useController<T>({ name });
|
|
37
|
+
const error = fieldState.error?.message;
|
|
38
|
+
const { onChange } = field;
|
|
39
|
+
|
|
40
|
+
const handleChange = useCallback(
|
|
41
|
+
(event: ChangeEvent<HTMLInputElement>) => {
|
|
42
|
+
if (!normalize) return onChange(event);
|
|
43
|
+
const next = normalize(event.target.value);
|
|
44
|
+
onChange(maxLength === undefined ? next : next.slice(0, maxLength));
|
|
45
|
+
},
|
|
46
|
+
[normalize, maxLength, onChange],
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<StepField label={label} htmlFor={name} error={error}>
|
|
51
|
+
<Input
|
|
52
|
+
id={name}
|
|
53
|
+
{...props}
|
|
54
|
+
{...field}
|
|
55
|
+
value={field.value ?? ""}
|
|
56
|
+
onChange={handleChange}
|
|
57
|
+
// Enforced after normalizing rather than by the DOM. The browser clips
|
|
58
|
+
// the raw text on paste, so `0532 123 45 67` would lose its last digits
|
|
59
|
+
// before the spaces were ever removed.
|
|
60
|
+
maxLength={normalize ? undefined : maxLength}
|
|
61
|
+
aria-invalid={Boolean(error)}
|
|
62
|
+
aria-describedby={error ? errorIdFor(name) : undefined}
|
|
63
|
+
/>
|
|
64
|
+
</StepField>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export { FormInput };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useController, type FieldValues, type Path } from "react-hook-form";
|
|
4
|
+
|
|
5
|
+
import { Select, type SelectProps } from "@/components/ui/select";
|
|
6
|
+
import { StepField, errorIdFor } from "@/components/steps/step-field";
|
|
7
|
+
|
|
8
|
+
export interface FormSelectProps<T extends FieldValues> extends Omit<
|
|
9
|
+
SelectProps,
|
|
10
|
+
"value" | "onValueChange" | "onClose" | "id" | "name" | "invalid" | "describedBy"
|
|
11
|
+
> {
|
|
12
|
+
name: Path<T>;
|
|
13
|
+
label: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Dropdown bound to the surrounding react-hook-form context.
|
|
18
|
+
*
|
|
19
|
+
* Closing the popup counts as the blur, which is what makes `onTouched`
|
|
20
|
+
* validation behave the same here as it does for text fields.
|
|
21
|
+
*/
|
|
22
|
+
function FormSelect<T extends FieldValues>({ name, label, ...props }: FormSelectProps<T>) {
|
|
23
|
+
const { field, fieldState } = useController<T>({ name });
|
|
24
|
+
const error = fieldState.error?.message;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<StepField label={label} htmlFor={name} error={error}>
|
|
28
|
+
<Select
|
|
29
|
+
{...props}
|
|
30
|
+
id={name}
|
|
31
|
+
name={field.name}
|
|
32
|
+
value={field.value ?? null}
|
|
33
|
+
invalid={Boolean(error)}
|
|
34
|
+
describedBy={error ? errorIdFor(name) : undefined}
|
|
35
|
+
onValueChange={field.onChange}
|
|
36
|
+
onClose={field.onBlur}
|
|
37
|
+
/>
|
|
38
|
+
</StepField>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export { FormSelect };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { Info } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
export interface StepFieldProps {
|
|
7
|
+
label: string;
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
/** Associates the label with the control it wraps, and names its error node. */
|
|
10
|
+
htmlFor?: string;
|
|
11
|
+
/** Validation message rendered under the control. */
|
|
12
|
+
error?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Id the control should point `aria-describedby` at when this field is invalid. */
|
|
16
|
+
export const errorIdFor = (name: string) => `${name}-error`;
|
|
17
|
+
|
|
18
|
+
/** Label + control + validation message. Layout only — it holds no form state. */
|
|
19
|
+
function StepField({ label, children, htmlFor, error }: StepFieldProps) {
|
|
20
|
+
return (
|
|
21
|
+
<div className="flex flex-col gap-1.5">
|
|
22
|
+
<label
|
|
23
|
+
htmlFor={htmlFor}
|
|
24
|
+
className="text-muted-foreground text-xs font-medium tracking-wide uppercase"
|
|
25
|
+
>
|
|
26
|
+
{label}
|
|
27
|
+
</label>
|
|
28
|
+
{children}
|
|
29
|
+
{error ? (
|
|
30
|
+
<p
|
|
31
|
+
id={htmlFor ? errorIdFor(htmlFor) : undefined}
|
|
32
|
+
role="alert"
|
|
33
|
+
className="text-brand flex items-center gap-1.5 text-xs font-medium"
|
|
34
|
+
>
|
|
35
|
+
<Info className="size-3.5 shrink-0" />
|
|
36
|
+
{error}
|
|
37
|
+
</p>
|
|
38
|
+
) : null}
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export { StepField };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Button as ButtonPrimitive } from "@base-ui/react/button";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
|
|
6
|
+
const buttonVariants = cva(
|
|
7
|
+
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
variant: {
|
|
11
|
+
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
|
12
|
+
brand: "bg-brand text-white hover:bg-[color-mix(in_oklab,var(--brand)_88%,white)]",
|
|
13
|
+
ink: "bg-cta text-white hover:bg-cta-hover",
|
|
14
|
+
outline:
|
|
15
|
+
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
|
|
16
|
+
secondary:
|
|
17
|
+
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
|
18
|
+
ghost:
|
|
19
|
+
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
|
|
20
|
+
destructive:
|
|
21
|
+
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20",
|
|
22
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
23
|
+
},
|
|
24
|
+
size: {
|
|
25
|
+
default:
|
|
26
|
+
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
27
|
+
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
|
28
|
+
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
|
29
|
+
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
30
|
+
// The form and purchase controls the quote flow uses — a full-height
|
|
31
|
+
// touch target, unlike the compact toolbar sizes above.
|
|
32
|
+
xl: "h-12 gap-2 px-6",
|
|
33
|
+
icon: "size-8",
|
|
34
|
+
"icon-xs":
|
|
35
|
+
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
|
36
|
+
"icon-sm":
|
|
37
|
+
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
|
38
|
+
"icon-lg": "size-9",
|
|
39
|
+
},
|
|
40
|
+
shape: {
|
|
41
|
+
default: "",
|
|
42
|
+
pill: "rounded-full",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
defaultVariants: {
|
|
46
|
+
variant: "default",
|
|
47
|
+
size: "default",
|
|
48
|
+
shape: "default",
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
function Button({
|
|
54
|
+
className,
|
|
55
|
+
variant = "default",
|
|
56
|
+
size = "default",
|
|
57
|
+
shape = "default",
|
|
58
|
+
...props
|
|
59
|
+
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
|
60
|
+
return (
|
|
61
|
+
<ButtonPrimitive
|
|
62
|
+
data-slot="button"
|
|
63
|
+
className={cn(buttonVariants({ variant, size, shape, className }))}
|
|
64
|
+
{...props}
|
|
65
|
+
/>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export { Button, buttonVariants };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Tooltip } from "@base-ui/react/tooltip";
|
|
4
|
+
import { Info } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Solid triangle on the popup edge nearest the field. Base UI places the element
|
|
10
|
+
* along the correct edge; the rotations below point it outward per side.
|
|
11
|
+
*/
|
|
12
|
+
const ARROW_CLASS =
|
|
13
|
+
"relative block h-1.5 w-3 overflow-clip data-[side=bottom]:top-[-6px] data-[side=left]:right-[-9px] data-[side=left]:rotate-90 data-[side=right]:left-[-9px] data-[side=right]:-rotate-90 data-[side=top]:bottom-[-6px] data-[side=top]:rotate-180 before:absolute before:bottom-0 before:left-1/2 before:h-[calc(6px*sqrt(2))] before:w-[calc(6px*sqrt(2))] before:bg-ink before:content-[''] before:[transform:translate(-50%,50%)_rotate(45deg)]";
|
|
14
|
+
|
|
15
|
+
export interface FieldHintContent {
|
|
16
|
+
title: string;
|
|
17
|
+
description: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface FieldHintProps {
|
|
21
|
+
hint: FieldHintContent;
|
|
22
|
+
/** Driven by the field's focus state — the field is the only trigger. */
|
|
23
|
+
open: boolean;
|
|
24
|
+
/** Ties the popup to the field through `aria-describedby`. */
|
|
25
|
+
id: string;
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Explanatory panel anchored to a field's trailing info mark.
|
|
31
|
+
*
|
|
32
|
+
* Built on Tooltip rather than Popover on purpose: a popover moves focus into
|
|
33
|
+
* itself when it opens, which would pull focus straight back out of the field
|
|
34
|
+
* that opened it. A tooltip never takes focus, and `role="tooltip"` is what this
|
|
35
|
+
* content actually is.
|
|
36
|
+
*
|
|
37
|
+
* The mark is a plain span with pointer events off, so hovering it does nothing;
|
|
38
|
+
* `open` is the single source of truth.
|
|
39
|
+
*/
|
|
40
|
+
function FieldHint({ hint, open, id, className }: FieldHintProps) {
|
|
41
|
+
return (
|
|
42
|
+
<Tooltip.Root open={open}>
|
|
43
|
+
<Tooltip.Trigger
|
|
44
|
+
render={<span aria-hidden />}
|
|
45
|
+
className={cn(
|
|
46
|
+
"group text-muted-foreground pointer-events-none absolute top-1/2 -translate-y-1/2",
|
|
47
|
+
className,
|
|
48
|
+
)}
|
|
49
|
+
>
|
|
50
|
+
<Info className="group-data-[popup-open]:fill-brand size-4 transition-colors group-data-[popup-open]:text-white" />
|
|
51
|
+
</Tooltip.Trigger>
|
|
52
|
+
<Tooltip.Portal>
|
|
53
|
+
<Tooltip.Positioner side="right" sideOffset={28} collisionPadding={16} className="z-50">
|
|
54
|
+
<Tooltip.Popup
|
|
55
|
+
id={id}
|
|
56
|
+
className="bg-ink w-72 max-w-[calc(100vw-2rem)] origin-[var(--transform-origin)] rounded-xl px-5 py-4 text-left shadow-xl transition-[opacity,scale] duration-150 data-ending-style:scale-98 data-ending-style:opacity-0 data-starting-style:scale-98 data-starting-style:opacity-0"
|
|
57
|
+
>
|
|
58
|
+
<Tooltip.Arrow className={ARROW_CLASS} />
|
|
59
|
+
<p className="text-sm font-semibold text-white">{hint.title}</p>
|
|
60
|
+
<p className="mt-1.5 text-xs leading-relaxed text-white/70">{hint.description}</p>
|
|
61
|
+
</Tooltip.Popup>
|
|
62
|
+
</Tooltip.Positioner>
|
|
63
|
+
</Tooltip.Portal>
|
|
64
|
+
</Tooltip.Root>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export { FieldHint };
|