diff --git a/app/layout.tsx b/app/layout.tsx index adc341f..cfd0942 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -14,7 +14,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - +
-
-
-

- A simple hello world component -

- -
-
- -
-
+ + + -
-
-

- A contact form with Zod validation. -

- -
-
- -
-
+ + + -
-
-

- A complex component showing hooks, libs and components. -

- -
-
- -
-
+ + + -
-
-

- A login form with a CSS file. -

- -
-
- -
-
+ + + -
-
-

- A component for displaying a list of badges with optional click - and delete handlers. -

- -
-
- -
-
+ + + -
-
-

- A pagination component with page size and page index controls. -

- -
-
- -
-
+ + + -
-
-

- An image preview component with modal viewing and download - functionality. -

- -
-
- -
-
+ + + -
-
-

- A file preview component supporting images, audio, video, and - documents. -

- -
-
- -
-
+ + + -
-
-

- A component that detects when scroll reaches edges using - IntersectionObserver. -

- -
-
- -
-
+ + + -
-
-

- An infinite scroll list component using MobX for state management. -

- -
-
- -
-
+ + + + + + + + + + + + + + + + + + + + + + +
); diff --git a/components/component-card.tsx b/components/component-card.tsx new file mode 100644 index 0000000..1f75a1b --- /dev/null +++ b/components/component-card.tsx @@ -0,0 +1,26 @@ +import { FC, PropsWithChildren } from "react"; + +import { OpenInV0Button } from "./open-in-v0-button"; + +export type ComponentCardProps = PropsWithChildren<{ + name: string; + description: string; + minHeight?: string; +}>; + +export const ComponentCard: FC = ({ + name, + description, + children, + minHeight = "min-h-[400px]", +}) => ( +
+
+

{description}

+ +
+
+ {children} +
+
+); diff --git a/components/index.ini b/components/index.ini index 2f092ff..e3be92f 100755 --- a/components/index.ini +++ b/components/index.ini @@ -1,4 +1,5 @@ badge button dialog -input \ No newline at end of file +input +label \ No newline at end of file diff --git a/registry.json b/registry.json index 1d40a65..46156dd 100644 --- a/registry.json +++ b/registry.json @@ -3,33 +3,6 @@ "name": "acme", "homepage": "https://acme.com", "items": [ - { - "name": "hello-world", - "type": "registry:component", - "title": "Hello World", - "description": "A simple hello world component", - "registryDependencies": ["button"], - "files": [ - { - "path": "registry/new-york/blocks/hello-world/hello-world.tsx", - "type": "registry:component" - } - ] - }, - { - "name": "example-form", - "type": "registry:component", - "title": "Example Form", - "description": "A contact form with Zod validation.", - "dependencies": ["zod"], - "registryDependencies": ["button", "input", "label", "textarea", "card"], - "files": [ - { - "path": "registry/new-york/blocks/example-form/example-form.tsx", - "type": "registry:component" - } - ] - }, { "name": "complex-component", "type": "registry:component", @@ -167,6 +140,88 @@ "type": "registry:component" } ] + }, + { + "name": "array-field", + "type": "registry:component", + "title": "Array Field", + "description": "A dynamic array field component with add/remove functionality for form arrays.", + "registryDependencies": ["button"], + "dependencies": [ + "lucide-react", + "mobx", + "mobx-react", + "mobx-react-helper", + "mobx-restful", + "web-utility" + ], + "files": [ + { + "path": "registry/new-york/blocks/array-field/array-field.tsx", + "type": "registry:component" + } + ] + }, + { + "name": "badge-input", + "type": "registry:component", + "title": "Badge Input", + "description": "An input component that displays values as removable badges, supporting multiple entries.", + "registryDependencies": ["badge"], + "dependencies": ["mobx-react", "mobx-react-helper", "web-utility"], + "files": [ + { + "path": "registry/new-york/blocks/badge-input/badge-input.tsx", + "type": "registry:component" + } + ] + }, + { + "name": "range-input", + "type": "registry:component", + "title": "Range Input", + "description": "A range slider input with optional custom icon display for each step.", + "dependencies": ["mobx-react", "mobx-react-helper"], + "files": [ + { + "path": "registry/new-york/blocks/range-input/range-input.tsx", + "type": "registry:component" + } + ] + }, + { + "name": "file-picker", + "type": "registry:component", + "title": "File Picker", + "description": "A file picker component with preview and remove functionality.", + "registryDependencies": ["button"], + "dependencies": [ + "lucide-react", + "mobx", + "mobx-react", + "mobx-react-helper", + "web-utility" + ], + "files": [ + { + "path": "registry/new-york/blocks/file-picker/file-picker.tsx", + "type": "registry:component" + } + ] + }, + { + "name": "form-field", + "type": "registry:component", + "title": "Form Field", + "description": "A unified form field component supporting input, textarea, and select elements with labels.", + "registryDependencies": ["input", "label"], + "dependencies": ["web-utility"], + "files": [ + { + "path": "registry/new-york/blocks/form-field/form-field.tsx", + "type": "registry:component" + } + ] } ] } diff --git a/registry/new-york/blocks/array-field/array-field.tsx b/registry/new-york/blocks/array-field/array-field.tsx new file mode 100644 index 0000000..51f636a --- /dev/null +++ b/registry/new-york/blocks/array-field/array-field.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { toJS } from "mobx"; +import { observer } from "mobx-react"; +import { FormComponent, FormComponentProps } from "mobx-react-helper"; +import { DataObject } from "mobx-restful"; +import { ChangeEvent, HTMLAttributes, ReactNode } from "react"; +import { Plus, Minus } from "lucide-react"; +import { formToJSON, isEmpty } from "web-utility"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export type ArrayFieldProps = Pick< + HTMLAttributes, + "className" | "style" +> & + FormComponentProps & { + renderItem: (item: T, index: number) => ReactNode; + }; + +@observer +export class ArrayField< + T extends DataObject = DataObject +> extends FormComponent> { + static displayName = "ArrayField"; + + componentDidMount() { + super.componentDidMount(); + + if (isEmpty(this.value)) this.add(); + } + + add = () => (this.innerValue = [...(this.innerValue || []), {} as T]); + + remove = (index: number) => + (this.innerValue = this.innerValue?.filter((_, i) => i !== index)); + + handleChange = + (index: number) => + ({ currentTarget }: ChangeEvent) => { + const item = formToJSON(currentTarget as HTMLFieldSetElement); + const { innerValue } = this; + + const list = [ + ...innerValue!.slice(0, index), + item, + ...innerValue!.slice(index + 1), + ].map((item) => toJS(item)); + + this.props.onChange?.(list); + }; + + handleUpdate = + (index: number) => + ({ currentTarget }: ChangeEvent) => + (this.innerValue![index] = formToJSON( + currentTarget as HTMLFieldSetElement + )); + + render() { + const { className = "", style, name, renderItem } = this.props; + + return ( + <> + {this.value?.map((item, index, { length }) => ( +
+
{renderItem(item, index)}
+
+ + +
+
+ ))} + + ); + } +} diff --git a/registry/new-york/blocks/array-field/example.tsx b/registry/new-york/blocks/array-field/example.tsx new file mode 100644 index 0000000..2612c6f --- /dev/null +++ b/registry/new-york/blocks/array-field/example.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { DataObject } from "mobx-restful"; + +import { Input } from "@/components/ui/input"; +import { ArrayField } from "./array-field"; + +interface TodoItem extends DataObject { + title: string; + completed?: boolean; +} + +export const ArrayFieldExample = () => ( +
+
+

Array Field with Inputs

+ + renderItem={({ title }, index) => ( + + )} + /> +
+
+); diff --git a/registry/new-york/blocks/badge-input/badge-input.tsx b/registry/new-york/blocks/badge-input/badge-input.tsx new file mode 100644 index 0000000..c0e3c46 --- /dev/null +++ b/registry/new-york/blocks/badge-input/badge-input.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { observer } from "mobx-react"; +import { FormComponent, FormComponentProps } from "mobx-react-helper"; +import { KeyboardEvent } from "react"; +import { isEmpty } from "web-utility"; + +import { cn } from "@/lib/utils"; +import { BadgeBar } from "../badge-bar/badge-bar"; + +export const TextInputTypes = [ + "text", + "number", + "tel", + "email", + "url", +] as const; + +export type TextInputType = (typeof TextInputTypes)[number]; + +export interface BadgeInputProps extends FormComponentProps { + type?: TextInputType; +} + +@observer +export class BadgeInput extends FormComponent { + static readonly displayName = "BadgeInput"; + + static match(type: string): type is TextInputType { + return TextInputTypes.includes(type as TextInputType); + } + + handleInput = (event: KeyboardEvent) => { + const input = event.currentTarget; + const { value } = input, + innerValue = this.innerValue || []; + + switch (event.key) { + case "Enter": { + event.preventDefault(); + input.value = ""; + + if (value) this.innerValue = [...innerValue, value]; + + break; + } + case "Backspace": { + if (!value) this.innerValue = innerValue.slice(0, -1); + } + } + }; + + delete(index: number) { + const innerValue = this.innerValue || []; + + this.innerValue = [ + ...innerValue.slice(0, index), + ...innerValue.slice(index + 1), + ]; + } + + render() { + const { value } = this, + { className, style, type, name, required, placeholder } = this.props; + + return ( +
+ ({ text }))} + onDelete={({}, index) => this.delete(index)} + /> + + +
+ ); + } +} diff --git a/registry/new-york/blocks/badge-input/example.tsx b/registry/new-york/blocks/badge-input/example.tsx new file mode 100644 index 0000000..943ff37 --- /dev/null +++ b/registry/new-york/blocks/badge-input/example.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useState } from "react"; + +import { BadgeInput } from "./badge-input"; + +export const BadgeInputExample = () => { + const [tags, setTags] = useState(["React", "TypeScript", "Next.js"]); + const [emails, setEmails] = useState(["user@example.com"]); + + return ( +
+
+

Tag Input

+ +

+ Press Enter to add a tag, Backspace to remove the last tag +

+
+ +
+

Email Input

+ +
+ +
+

Current Values

+
+
+ Tags: +
+              {JSON.stringify(tags, null, 2)}
+            
+
+
+ Emails: +
+              {JSON.stringify(emails, null, 2)}
+            
+
+
+
+
+ ); +}; diff --git a/registry/new-york/blocks/file-picker/example.tsx b/registry/new-york/blocks/file-picker/example.tsx new file mode 100644 index 0000000..b2248b8 --- /dev/null +++ b/registry/new-york/blocks/file-picker/example.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useState } from "react"; + +import { FilePicker } from "./file-picker"; + +export const FilePickerExample = () => { + const [imageFile, setImageFile] = useState(""); + const [documentFile, setDocumentFile] = useState(""); + + return ( +
+
+

Image Picker

+ +

+ Click to upload an image +

+
+ +
+

Document Picker

+ +

+ Click to upload a document +

+
+ +
+

Selected Files

+
+
+ Image: +
+              {imageFile instanceof File
+                ? `File: ${imageFile.name} (${imageFile.size} bytes)`
+                : imageFile || "No file selected"}
+            
+
+
+ Document: +
+              {documentFile instanceof File
+                ? `File: ${documentFile.name} (${documentFile.size} bytes)`
+                : documentFile || "No file selected"}
+            
+
+
+
+
+ ); +}; diff --git a/registry/new-york/blocks/file-picker/file-picker.tsx b/registry/new-york/blocks/file-picker/file-picker.tsx new file mode 100644 index 0000000..b78e4b4 --- /dev/null +++ b/registry/new-york/blocks/file-picker/file-picker.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { computed, observable } from "mobx"; +import { observer } from "mobx-react"; +import { FormComponent, FormComponentProps, reaction } from "mobx-react-helper"; +import { X } from "lucide-react"; +import { blobOf } from "web-utility"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { FilePreview } from "../file-preview/file-preview"; + +export type FilePickerProps = FormComponentProps; + +const blobCache = new WeakMap(); + +@observer +export class FilePicker extends FormComponent { + static readonly displayName = "FilePicker"; + + @observable + accessor file: File | undefined; + + @computed + get fileType() { + const { accept } = this.observedProps, + { file } = this; + + return file?.type || file?.name.match(/\.\w+$/)?.[0] || accept; + } + + @computed + get filePath() { + const { value } = this; + + return typeof value === "string" ? value : value && blobCache.get(value); + } + + @reaction(({ value }) => value) + protected async restoreFile(data: FilePickerProps["value"]) { + if (typeof data === "string") + try { + const blob = await blobOf(data), + name = data.split("/").at(-1); + const file = new File([blob], name!, { type: blob.type }); + + blobCache.set(file, data); + + return (this.file = file); + } catch {} + + if (data instanceof File) { + if (!blobCache.has(data)) blobCache.set(data, URL.createObjectURL(data)); + + return (this.file = data); + } + return (this.file = undefined); + } + + #changeFile = (data?: File) => { + this.file = data; + + if (data) { + this.innerValue = data; + + blobCache.set(data, URL.createObjectURL(data)); + } else if (this.value) { + const { innerValue } = this; + + if (typeof innerValue === "string" && innerValue.startsWith("blob:")) + URL.revokeObjectURL(innerValue); + else if (innerValue instanceof File) blobCache.delete(innerValue); + + this.innerValue = ""; + } + }; + + componentDidMount() { + super.componentDidMount(); + + this.restoreFile(this.value); + } + + renderInput() { + const { id, name, value, required, disabled, accept, multiple } = + this.props; + const { filePath } = this; + + return ( + <> + + this.#changeFile(files?.[0]) + } + /> + {filePath && } + + ); + } + + render() { + const { filePath, fileType } = this, + { className = "", style } = this.props; + + return ( +
+ {filePath ? ( + + ) : ( +
+ + +
+ )} + {this.renderInput()} + {filePath && ( + + )} +
+ ); + } +} diff --git a/registry/new-york/blocks/form-field/example.tsx b/registry/new-york/blocks/form-field/example.tsx new file mode 100644 index 0000000..124b993 --- /dev/null +++ b/registry/new-york/blocks/form-field/example.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { FormField } from "./form-field"; + +export const FormFieldExample = () => ( +
+
+

Text Input

+ +
+ +
+

Email Input

+ +
+ +
+

Textarea

+ +
+ +
+

Select Dropdown

+ +
+ +
+

Multiple Select

+ +
+
+); diff --git a/registry/new-york/blocks/form-field/form-field.tsx b/registry/new-york/blocks/form-field/form-field.tsx new file mode 100644 index 0000000..d474d4d --- /dev/null +++ b/registry/new-york/blocks/form-field/form-field.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { ComponentProps, FC, FocusEvent, TextareaHTMLAttributes } from "react"; +import { uniqueID } from "web-utility"; + +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; + +export interface SelectOption + extends Partial> { + value: string; +} + +export type FormFieldProps = ComponentProps & + Pick, "rows"> & { + label?: string; + options?: SelectOption[]; + multiple?: boolean; + }; + +export const FormField: FC = ({ + className, + style, + name, + id = name || `form-field-${uniqueID()}`, + label, + placeholder = typeof label === "string" ? label : id, + options, + multiple, + rows, + onBlur, + ...controlProps +}) => ( +
{ + if ((event.target as HTMLInputElement).checkValidity()) { + event.target.classList.remove("border-destructive"); + } else { + event.target.classList.add("border-destructive"); + } + onBlur?.(event as unknown as FocusEvent); + }} + > + {label && } + {options ? ( + + ) : rows && rows > 1 ? ( +