@withmata/blueprints 0.3.5 → 0.4.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/.claude/skills/audit/SKILL.md +4 -4
- package/.claude/skills/blueprint-catalog/SKILL.md +17 -7
- package/.claude/skills/copywrite/SKILL.md +187 -0
- package/.claude/skills/copywrite-landing/SKILL.md +489 -0
- package/.claude/skills/design-system/SKILL.md +970 -0
- package/.claude/skills/new-project/SKILL.md +168 -112
- package/.claude/skills/scaffold-auth/SKILL.md +9 -9
- package/.claude/skills/scaffold-db/SKILL.md +14 -14
- package/.claude/skills/scaffold-env/SKILL.md +4 -4
- package/.claude/skills/scaffold-foundation/SKILL.md +15 -15
- package/.claude/skills/scaffold-tailwind/SKILL.md +17 -3
- package/.claude/skills/scaffold-ui/SKILL.md +155 -36
- package/ENGINEERING.md +2 -2
- package/blueprints/discovery/design-system/BLUEPRINT.md +1479 -0
- package/blueprints/discovery/marketing-copywriting/BLUEPRINT.md +664 -0
- package/blueprints/features/auth-better-auth/BLUEPRINT.md +20 -22
- package/blueprints/features/db-drizzle-postgres/BLUEPRINT.md +12 -12
- package/blueprints/features/db-drizzle-postgres/files/db/src/example-entity.ts +1 -1
- package/blueprints/features/db-drizzle-postgres/files/db/src/scripts/seed.ts +1 -1
- package/blueprints/features/env-t3/BLUEPRINT.md +1 -1
- package/blueprints/features/tailwind-v4/BLUEPRINT.md +9 -2
- package/blueprints/features/tailwind-v4/files/tailwind-config/shared-styles.css +80 -1
- package/blueprints/features/ui-shared-components/BLUEPRINT.md +411 -78
- package/blueprints/features/ui-shared-components/files/ui/components/ui/alert-dialog.tsx +192 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/avatar.tsx +71 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/badge.tsx +52 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/breadcrumb.tsx +122 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/button.tsx +56 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/card-select.tsx +72 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/card.tsx +100 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/collapsible.tsx +34 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/combobox.tsx +301 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/dropdown-menu.tsx +264 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/empty-state.tsx +43 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/entity-select.tsx +110 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/field.tsx +237 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/form-field.tsx +217 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/input-group.tsx +161 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/input.tsx +20 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/label.tsx +20 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/org-switcher.tsx +114 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/page-header.tsx +45 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/pagination.tsx +52 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/pill-select.tsx +151 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/popover.tsx +41 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/search-input.tsx +49 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/select.tsx +205 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/selected-entity-card.tsx +47 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/separator.tsx +25 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/sidebar.tsx +389 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/status-filter.tsx +43 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/tag-input.tsx +131 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/textarea.tsx +18 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/user-menu.tsx +149 -0
- package/blueprints/features/ui-shared-components/files/ui/components.json +11 -8
- package/blueprints/features/ui-shared-components/files/ui/package.json +20 -11
- package/blueprints/foundation/monorepo-turbo/BLUEPRINT.md +19 -20
- package/blueprints/foundation/monorepo-turbo/files/root/package.json +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/blueprints/features/tailwind-v4/files/tailwind-config/package.json +0 -20
- package/blueprints/foundation/monorepo-turbo/files/root/pnpm-workspace.yaml +0 -5
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Combobox,
|
|
5
|
+
ComboboxContent,
|
|
6
|
+
ComboboxInput,
|
|
7
|
+
ComboboxItem,
|
|
8
|
+
ComboboxList,
|
|
9
|
+
ComboboxSeparator,
|
|
10
|
+
} from "#components/ui/combobox";
|
|
11
|
+
import { PlusIcon } from "@phosphor-icons/react/Plus";
|
|
12
|
+
import { type ReactNode, useState } from "react";
|
|
13
|
+
|
|
14
|
+
export interface EntitySelectProps<T> {
|
|
15
|
+
value: string;
|
|
16
|
+
onValueChange: (value: string) => void;
|
|
17
|
+
options: T[];
|
|
18
|
+
getOptionId: (option: T) => string;
|
|
19
|
+
getOptionName: (option: T) => string;
|
|
20
|
+
renderOption: (option: T) => ReactNode;
|
|
21
|
+
renderSelected: (option: T, onRemove: () => void) => ReactNode;
|
|
22
|
+
placeholder?: string;
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
isLoading?: boolean;
|
|
25
|
+
createLabel?: string;
|
|
26
|
+
onCreateNew?: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function EntitySelect<T>({
|
|
30
|
+
value,
|
|
31
|
+
onValueChange,
|
|
32
|
+
options,
|
|
33
|
+
getOptionId,
|
|
34
|
+
getOptionName,
|
|
35
|
+
renderOption,
|
|
36
|
+
renderSelected,
|
|
37
|
+
placeholder = "Select...",
|
|
38
|
+
disabled = false,
|
|
39
|
+
isLoading = false,
|
|
40
|
+
createLabel = "Create new",
|
|
41
|
+
onCreateNew,
|
|
42
|
+
}: EntitySelectProps<T>) {
|
|
43
|
+
const [inputValue, setInputValue] = useState("");
|
|
44
|
+
|
|
45
|
+
const selectedOption = options.find((o) => getOptionId(o) === value);
|
|
46
|
+
|
|
47
|
+
// If a value is selected, show the selected card instead of the combobox
|
|
48
|
+
if (selectedOption) {
|
|
49
|
+
return renderSelected(selectedOption, () => {
|
|
50
|
+
onValueChange("");
|
|
51
|
+
setInputValue("");
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const filteredOptions = inputValue
|
|
56
|
+
? options.filter((o) =>
|
|
57
|
+
getOptionName(o).toLowerCase().includes(inputValue.toLowerCase()),
|
|
58
|
+
)
|
|
59
|
+
: options;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Combobox
|
|
63
|
+
value={value}
|
|
64
|
+
onValueChange={(val) => {
|
|
65
|
+
if (val === "__create_new__") {
|
|
66
|
+
onCreateNew?.();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
onValueChange(val as string);
|
|
70
|
+
setInputValue("");
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
<ComboboxInput
|
|
74
|
+
placeholder={placeholder}
|
|
75
|
+
disabled={disabled}
|
|
76
|
+
value={inputValue}
|
|
77
|
+
onChange={(e) => setInputValue(e.target.value)}
|
|
78
|
+
/>
|
|
79
|
+
<ComboboxContent>
|
|
80
|
+
<ComboboxList>
|
|
81
|
+
{isLoading && filteredOptions.length === 0 && (
|
|
82
|
+
<p className="text-muted-foreground flex items-center justify-center gap-2 py-2 text-center text-sm">
|
|
83
|
+
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
|
84
|
+
Loading...
|
|
85
|
+
</p>
|
|
86
|
+
)}
|
|
87
|
+
{!isLoading && filteredOptions.length === 0 && options.length > 0 && (
|
|
88
|
+
<p className="text-muted-foreground py-2 text-center text-sm">
|
|
89
|
+
No results found
|
|
90
|
+
</p>
|
|
91
|
+
)}
|
|
92
|
+
{filteredOptions.map((option) => (
|
|
93
|
+
<ComboboxItem key={getOptionId(option)} value={getOptionId(option)}>
|
|
94
|
+
{renderOption(option)}
|
|
95
|
+
</ComboboxItem>
|
|
96
|
+
))}
|
|
97
|
+
{onCreateNew && (
|
|
98
|
+
<>
|
|
99
|
+
{(isLoading || options.length > 0) && <ComboboxSeparator />}
|
|
100
|
+
<ComboboxItem value="__create_new__" className="py-2.5">
|
|
101
|
+
<PlusIcon className="size-4" />
|
|
102
|
+
{createLabel}
|
|
103
|
+
</ComboboxItem>
|
|
104
|
+
</>
|
|
105
|
+
)}
|
|
106
|
+
</ComboboxList>
|
|
107
|
+
</ComboboxContent>
|
|
108
|
+
</Combobox>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
|
+
import { useMemo } from "react";
|
|
5
|
+
import { Label } from "#components/ui/label";
|
|
6
|
+
import { Separator } from "#components/ui/separator";
|
|
7
|
+
import { cn } from "#utils/cn";
|
|
8
|
+
|
|
9
|
+
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
|
10
|
+
return (
|
|
11
|
+
<fieldset
|
|
12
|
+
data-slot="field-set"
|
|
13
|
+
className={cn(
|
|
14
|
+
"gap-6 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 flex flex-col",
|
|
15
|
+
className,
|
|
16
|
+
)}
|
|
17
|
+
{...props}
|
|
18
|
+
/>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function FieldLegend({
|
|
23
|
+
className,
|
|
24
|
+
variant = "legend",
|
|
25
|
+
...props
|
|
26
|
+
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
|
27
|
+
return (
|
|
28
|
+
<legend
|
|
29
|
+
data-slot="field-legend"
|
|
30
|
+
data-variant={variant}
|
|
31
|
+
className={cn(
|
|
32
|
+
"mb-3 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
|
33
|
+
className,
|
|
34
|
+
)}
|
|
35
|
+
{...props}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
data-slot="field-group"
|
|
44
|
+
className={cn(
|
|
45
|
+
"gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4 group/field-group @container/field-group flex w-full flex-col",
|
|
46
|
+
className,
|
|
47
|
+
)}
|
|
48
|
+
{...props}
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const fieldVariants = cva(
|
|
54
|
+
"data-[invalid=true]:text-destructive gap-1.5 group/field flex w-full",
|
|
55
|
+
{
|
|
56
|
+
variants: {
|
|
57
|
+
orientation: {
|
|
58
|
+
vertical: "flex-col [&>*]:w-full [&>.sr-only]:w-auto",
|
|
59
|
+
horizontal:
|
|
60
|
+
"flex-row items-center [&>[data-slot=field-label]]:flex-auto has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
|
61
|
+
responsive:
|
|
62
|
+
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto @md/field-group:[&>[data-slot=field-label]]:flex-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
defaultVariants: {
|
|
66
|
+
orientation: "vertical",
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
function Field({
|
|
72
|
+
className,
|
|
73
|
+
orientation = "vertical",
|
|
74
|
+
...props
|
|
75
|
+
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
|
76
|
+
return (
|
|
77
|
+
<div
|
|
78
|
+
role="group"
|
|
79
|
+
data-slot="field"
|
|
80
|
+
data-orientation={orientation}
|
|
81
|
+
className={cn(fieldVariants({ orientation }), className)}
|
|
82
|
+
{...props}
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
88
|
+
return (
|
|
89
|
+
<div
|
|
90
|
+
data-slot="field-content"
|
|
91
|
+
className={cn(
|
|
92
|
+
"gap-1 group/field-content flex flex-1 flex-col leading-snug",
|
|
93
|
+
className,
|
|
94
|
+
)}
|
|
95
|
+
{...props}
|
|
96
|
+
/>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function FieldLabel({
|
|
101
|
+
className,
|
|
102
|
+
...props
|
|
103
|
+
}: React.ComponentProps<typeof Label>) {
|
|
104
|
+
return (
|
|
105
|
+
<Label
|
|
106
|
+
data-slot="field-label"
|
|
107
|
+
className={cn(
|
|
108
|
+
"has-data-checked:bg-primary/5 has-data-checked:border-primary/50 dark:has-data-checked:bg-primary/10 gap-0.5 group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-xl has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4 group/field-label peer/field-label flex w-fit leading-snug",
|
|
109
|
+
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
|
110
|
+
className,
|
|
111
|
+
)}
|
|
112
|
+
{...props}
|
|
113
|
+
/>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
118
|
+
return (
|
|
119
|
+
<div
|
|
120
|
+
data-slot="field-label"
|
|
121
|
+
className={cn(
|
|
122
|
+
"gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50 flex w-fit items-center leading-snug",
|
|
123
|
+
className,
|
|
124
|
+
)}
|
|
125
|
+
{...props}
|
|
126
|
+
/>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
|
131
|
+
return (
|
|
132
|
+
<p
|
|
133
|
+
data-slot="field-description"
|
|
134
|
+
className={cn(
|
|
135
|
+
"text-muted-foreground text-left text-xs [[data-variant=legend]+&]:-mt-1.5 leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
|
|
136
|
+
"last:mt-0 nth-last-2:-mt-1",
|
|
137
|
+
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
|
138
|
+
className,
|
|
139
|
+
)}
|
|
140
|
+
{...props}
|
|
141
|
+
/>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function FieldSeparator({
|
|
146
|
+
children,
|
|
147
|
+
className,
|
|
148
|
+
...props
|
|
149
|
+
}: React.ComponentProps<"div"> & {
|
|
150
|
+
children?: React.ReactNode;
|
|
151
|
+
}) {
|
|
152
|
+
return (
|
|
153
|
+
<div
|
|
154
|
+
data-slot="field-separator"
|
|
155
|
+
data-content={!!children}
|
|
156
|
+
className={cn(
|
|
157
|
+
"-my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2 relative",
|
|
158
|
+
className,
|
|
159
|
+
)}
|
|
160
|
+
{...props}
|
|
161
|
+
>
|
|
162
|
+
<Separator className="absolute inset-0 top-1/2" />
|
|
163
|
+
{children && (
|
|
164
|
+
<span
|
|
165
|
+
className="text-muted-foreground px-2 bg-background relative mx-auto block w-fit"
|
|
166
|
+
data-slot="field-separator-content"
|
|
167
|
+
>
|
|
168
|
+
{children}
|
|
169
|
+
</span>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function FieldError({
|
|
176
|
+
className,
|
|
177
|
+
children,
|
|
178
|
+
errors,
|
|
179
|
+
...props
|
|
180
|
+
}: React.ComponentProps<"div"> & {
|
|
181
|
+
errors?: Array<{ message?: string } | undefined>;
|
|
182
|
+
}) {
|
|
183
|
+
const content = useMemo(() => {
|
|
184
|
+
if (children) {
|
|
185
|
+
return children;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!errors?.length) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const uniqueErrors = [
|
|
193
|
+
...new Map(errors.map((error) => [error?.message, error])).values(),
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
if (uniqueErrors?.length === 1) {
|
|
197
|
+
return uniqueErrors[0]?.message;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<ul className="ml-4 flex list-disc flex-col gap-1">
|
|
202
|
+
{uniqueErrors.map(
|
|
203
|
+
(error, index) =>
|
|
204
|
+
error?.message && <li key={index}>{error.message}</li>,
|
|
205
|
+
)}
|
|
206
|
+
</ul>
|
|
207
|
+
);
|
|
208
|
+
}, [children, errors]);
|
|
209
|
+
|
|
210
|
+
if (!content) {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<div
|
|
216
|
+
role="alert"
|
|
217
|
+
data-slot="field-error"
|
|
218
|
+
className={cn("text-destructive text-sm font-normal", className)}
|
|
219
|
+
{...props}
|
|
220
|
+
>
|
|
221
|
+
{content}
|
|
222
|
+
</div>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export {
|
|
227
|
+
Field,
|
|
228
|
+
FieldLabel,
|
|
229
|
+
FieldDescription,
|
|
230
|
+
FieldError,
|
|
231
|
+
FieldGroup,
|
|
232
|
+
FieldLegend,
|
|
233
|
+
FieldSeparator,
|
|
234
|
+
FieldSet,
|
|
235
|
+
FieldContent,
|
|
236
|
+
FieldTitle,
|
|
237
|
+
};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Info, X } from "@phosphor-icons/react";
|
|
4
|
+
import { useEffect, useRef } from "react";
|
|
5
|
+
import type * as React from "react";
|
|
6
|
+
import { Field, FieldDescription, FieldLabel } from "#components/ui/field";
|
|
7
|
+
import {
|
|
8
|
+
Popover,
|
|
9
|
+
PopoverAnchor,
|
|
10
|
+
PopoverClose,
|
|
11
|
+
PopoverContent,
|
|
12
|
+
} from "#components/ui/popover";
|
|
13
|
+
|
|
14
|
+
export interface FieldHint {
|
|
15
|
+
directive: string;
|
|
16
|
+
example: string;
|
|
17
|
+
label?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface FormFieldProps {
|
|
21
|
+
label: string;
|
|
22
|
+
name: string;
|
|
23
|
+
description?: string | React.ReactNode;
|
|
24
|
+
counter?: React.ReactNode;
|
|
25
|
+
required?: boolean;
|
|
26
|
+
error?: string;
|
|
27
|
+
onFocus?: (element: HTMLElement) => void;
|
|
28
|
+
hint?: FieldHint;
|
|
29
|
+
isHintOpen?: boolean;
|
|
30
|
+
onHintOpen?: (fieldName: string) => void;
|
|
31
|
+
onHintClose?: () => void;
|
|
32
|
+
showHints?: boolean;
|
|
33
|
+
autoShowOnFocus?: boolean;
|
|
34
|
+
onToggleHints?: () => void;
|
|
35
|
+
children: React.ReactNode;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function FormField({
|
|
39
|
+
label,
|
|
40
|
+
name,
|
|
41
|
+
description,
|
|
42
|
+
counter,
|
|
43
|
+
required = false,
|
|
44
|
+
error,
|
|
45
|
+
onFocus,
|
|
46
|
+
hint,
|
|
47
|
+
isHintOpen = false,
|
|
48
|
+
onHintOpen,
|
|
49
|
+
onHintClose,
|
|
50
|
+
showHints = true,
|
|
51
|
+
autoShowOnFocus = true,
|
|
52
|
+
onToggleHints,
|
|
53
|
+
children,
|
|
54
|
+
}: FormFieldProps) {
|
|
55
|
+
const anchorRef = useRef<HTMLDivElement>(null);
|
|
56
|
+
|
|
57
|
+
// Hide popover when anchor scrolls out of its scroll container
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!isHintOpen || !hint || !anchorRef.current) return;
|
|
60
|
+
|
|
61
|
+
const el = anchorRef.current;
|
|
62
|
+
// Find the nearest scrollable ancestor
|
|
63
|
+
const scrollParent = el.closest("[data-slot='scroll-area'], .overflow-y-auto, [style*='overflow']") || el.parentElement;
|
|
64
|
+
if (!scrollParent) return;
|
|
65
|
+
|
|
66
|
+
const observer = new IntersectionObserver(
|
|
67
|
+
([entry]) => {
|
|
68
|
+
if (!entry?.isIntersecting) {
|
|
69
|
+
onHintClose?.();
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
{ root: scrollParent, threshold: 0.1 },
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
observer.observe(el);
|
|
76
|
+
return () => observer.disconnect();
|
|
77
|
+
}, [isHintOpen, hint, onHintClose]);
|
|
78
|
+
|
|
79
|
+
const handleFocusCapture = (e: React.FocusEvent<HTMLDivElement>) => {
|
|
80
|
+
if (onFocus && e.currentTarget) {
|
|
81
|
+
onFocus(e.currentTarget);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (hint && showHints && autoShowOnFocus && onHintOpen) {
|
|
85
|
+
onHintOpen(name);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const handleInfoClick = () => {
|
|
90
|
+
if (isHintOpen) {
|
|
91
|
+
onHintClose?.();
|
|
92
|
+
} else {
|
|
93
|
+
onHintOpen?.(name);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// The directive from the hint replaces the description when available
|
|
98
|
+
const displayDescription = hint ? hint.directive : description;
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<Field className="!gap-0">
|
|
102
|
+
{/* Label + description group — tight spacing */}
|
|
103
|
+
<div className="space-y-1.5">
|
|
104
|
+
<div className="flex items-center gap-1.5">
|
|
105
|
+
<FieldLabel htmlFor={name} className="mb-0 text-sm">
|
|
106
|
+
{label}
|
|
107
|
+
{required && <span className="text-destructive">*</span>}
|
|
108
|
+
</FieldLabel>
|
|
109
|
+
{hint && (
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
113
|
+
onClick={handleInfoClick}
|
|
114
|
+
aria-label={`Show hint for ${label}`}
|
|
115
|
+
>
|
|
116
|
+
<Info className="size-3.5" />
|
|
117
|
+
</button>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
{(displayDescription || counter) && (
|
|
121
|
+
<div className="flex items-center justify-between gap-2">
|
|
122
|
+
{displayDescription && (
|
|
123
|
+
<FieldDescription>{displayDescription}</FieldDescription>
|
|
124
|
+
)}
|
|
125
|
+
{counter && (
|
|
126
|
+
<span className="text-xs text-muted-foreground shrink-0">
|
|
127
|
+
{counter}
|
|
128
|
+
</span>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
{/* Field input — more space above, tight to error below */}
|
|
135
|
+
<div className="mt-3">
|
|
136
|
+
<Popover
|
|
137
|
+
open={isHintOpen && !!hint}
|
|
138
|
+
onOpenChange={(open) => {
|
|
139
|
+
if (!open) onHintClose?.();
|
|
140
|
+
}}
|
|
141
|
+
>
|
|
142
|
+
<PopoverAnchor asChild>
|
|
143
|
+
<div ref={anchorRef} onFocusCapture={handleFocusCapture}>
|
|
144
|
+
{children}
|
|
145
|
+
</div>
|
|
146
|
+
</PopoverAnchor>
|
|
147
|
+
<PopoverContent
|
|
148
|
+
side="right"
|
|
149
|
+
align="start"
|
|
150
|
+
sideOffset={20}
|
|
151
|
+
collisionPadding={16}
|
|
152
|
+
className="w-80 p-0 overflow-hidden"
|
|
153
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
154
|
+
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
155
|
+
onFocusOutside={(e) => e.preventDefault()}
|
|
156
|
+
hideWhenDetached
|
|
157
|
+
>
|
|
158
|
+
{/* Arrow */}
|
|
159
|
+
<div className="absolute left-0 top-4 -translate-x-full">
|
|
160
|
+
<div className="w-0 h-0 border-t-[8px] border-t-transparent border-b-[8px] border-b-transparent border-r-[8px] border-r-border" />
|
|
161
|
+
<div className="w-0 h-0 border-t-[7px] border-t-transparent border-b-[7px] border-b-transparent border-r-[7px] border-r-popover absolute top-[1px] left-[1px]" />
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
{/* Header: info icon + "Info" label + close */}
|
|
165
|
+
<div className="px-3 pt-2.5 pb-2 flex items-center gap-1.5">
|
|
166
|
+
<div className="flex items-center gap-1.5">
|
|
167
|
+
<Info className="size-4 text-primary shrink-0" />
|
|
168
|
+
<span className="text-xs text-primary leading-none translate-y-px">
|
|
169
|
+
Info
|
|
170
|
+
</span>
|
|
171
|
+
</div>
|
|
172
|
+
<div className="flex-1" />
|
|
173
|
+
<PopoverClose asChild>
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
|
177
|
+
aria-label="Close hint"
|
|
178
|
+
>
|
|
179
|
+
<X className="size-3.5" weight="bold" />
|
|
180
|
+
</button>
|
|
181
|
+
</PopoverClose>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
{/* Example content */}
|
|
185
|
+
<div className="bg-muted px-3 py-2.5 border-t border-border/50">
|
|
186
|
+
<p className="text-[11px] font-medium text-muted-foreground mb-1">
|
|
187
|
+
{hint?.label ?? "Good example"}
|
|
188
|
+
</p>
|
|
189
|
+
<p className="text-sm leading-relaxed">{hint?.example}</p>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
{/* Footer: toggle hints */}
|
|
193
|
+
{onToggleHints && (
|
|
194
|
+
<div className="px-3 pt-1.5 pb-2.5 border-t border-border/50">
|
|
195
|
+
<button
|
|
196
|
+
type="button"
|
|
197
|
+
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline transition-colors"
|
|
198
|
+
onClick={() => {
|
|
199
|
+
if (showHints) {
|
|
200
|
+
onHintClose?.();
|
|
201
|
+
}
|
|
202
|
+
onToggleHints();
|
|
203
|
+
}}
|
|
204
|
+
>
|
|
205
|
+
{showHints
|
|
206
|
+
? "Don\u0027t show hints"
|
|
207
|
+
: "Always show hints"}
|
|
208
|
+
</button>
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
</PopoverContent>
|
|
212
|
+
</Popover>
|
|
213
|
+
</div>
|
|
214
|
+
{error && <p className="mt-1.5 text-sm text-destructive">{error}</p>}
|
|
215
|
+
</Field>
|
|
216
|
+
);
|
|
217
|
+
}
|