From 117e2c7707224ed7cc82feefe5c8b590b891a44f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 22:31:04 +0000 Subject: [PATCH 1/6] Initial plan From 88e80d56df17d20142e7cfd530b8dc097d294dc8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 22:38:10 +0000 Subject: [PATCH 2/6] Implement 6 new Shadcn UI components with examples Co-authored-by: TechQuery <19969570+TechQuery@users.noreply.github.com> --- registry.json | 112 +++++++++++ .../blocks/array-field/array-field.tsx | 98 +++++++++ .../new-york/blocks/array-field/example.tsx | 45 +++++ .../blocks/badge-input/badge-input.tsx | 82 ++++++++ .../new-york/blocks/badge-input/example.tsx | 55 ++++++ .../new-york/blocks/file-picker/example.tsx | 60 ++++++ .../blocks/file-picker/file-picker.tsx | 141 +++++++++++++ .../new-york/blocks/form-field/example.tsx | 73 +++++++ .../new-york/blocks/form-field/form-field.tsx | 111 +++++++++++ .../new-york/blocks/range-input/example.tsx | 57 ++++++ .../blocks/range-input/range-input.tsx | 63 ++++++ .../blocks/searchable-input/example.tsx | 89 +++++++++ .../searchable-input/searchable-input.tsx | 186 ++++++++++++++++++ 13 files changed, 1172 insertions(+) create mode 100644 registry/new-york/blocks/array-field/array-field.tsx create mode 100644 registry/new-york/blocks/array-field/example.tsx create mode 100644 registry/new-york/blocks/badge-input/badge-input.tsx create mode 100644 registry/new-york/blocks/badge-input/example.tsx create mode 100644 registry/new-york/blocks/file-picker/example.tsx create mode 100644 registry/new-york/blocks/file-picker/file-picker.tsx create mode 100644 registry/new-york/blocks/form-field/example.tsx create mode 100644 registry/new-york/blocks/form-field/form-field.tsx create mode 100644 registry/new-york/blocks/range-input/example.tsx create mode 100644 registry/new-york/blocks/range-input/range-input.tsx create mode 100644 registry/new-york/blocks/searchable-input/example.tsx create mode 100644 registry/new-york/blocks/searchable-input/searchable-input.tsx diff --git a/registry.json b/registry.json index 1d40a65..71702f8 100644 --- a/registry.json +++ b/registry.json @@ -167,6 +167,118 @@ "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", + "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": [ + "lucide-react", + "mobx", + "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"], + "files": [ + { + "path": "registry/new-york/blocks/form-field/form-field.tsx", + "type": "registry:component" + } + ] + }, + { + "name": "searchable-input", + "type": "registry:component", + "title": "Searchable Input", + "description": "A searchable select input with badge display, supporting single or multiple selection.", + "registryDependencies": ["button", "input"], + "dependencies": [ + "lodash.debounce", + "mobx", + "mobx-react", + "mobx-react-helper", + "mobx-restful", + "web-utility" + ], + "files": [ + { + "path": "registry/new-york/blocks/searchable-input/searchable-input.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..578183a --- /dev/null +++ b/registry/new-york/blocks/array-field/array-field.tsx @@ -0,0 +1,98 @@ +"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..ad1d642 --- /dev/null +++ b/registry/new-york/blocks/array-field/example.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useState } from "react"; +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 = () => { + const [items, setItems] = useState([ + { title: "First task" }, + { title: "Second task" }, + ]); + + return ( +
+
+

Array Field with Inputs

+ + value={items} + onChange={setItems} + renderItem={(item, index) => ( + + )} + /> +
+ +
+

Current Values

+
+          {JSON.stringify(items, null, 2)}
+        
+
+
+ ); +}; 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..3f260a2 --- /dev/null +++ b/registry/new-york/blocks/badge-input/badge-input.tsx @@ -0,0 +1,82 @@ +"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 "@/registry/new-york/blocks/badge-bar/badge-bar"; + +export const TextInputTypes = ["text", "number", "tel", "email", "url"] as const; + +export interface BadgeInputProps extends FormComponentProps { + type?: (typeof TextInputTypes)[number]; +} + +@observer +export class BadgeInput extends FormComponent { + static readonly displayName = "BadgeInput"; + + static match(type: string): type is BadgeInputProps["type"] { + return TextInputTypes.includes(type as BadgeInputProps["type"]); + } + + handleInput = (event: KeyboardEvent) => { + const input = event.currentTarget; + const { value } = input; + const 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; + + this.innerValue = [ + ...innerValue.slice(0, index), + ...innerValue.slice(index + 1), + ]; + } + + render() { + const { value } = this; + const { 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..ec1fee5 --- /dev/null +++ b/registry/new-york/blocks/file-picker/file-picker.tsx @@ -0,0 +1,141 @@ +"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 "@/registry/new-york/blocks/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; + const { file } = this; + + return file?.type || file?.name.match(/\.\w+$/)?.[0] || accept; + } + + @computed + get filePath() { + const { value } = this; + + return typeof value === "string" ? value : blobCache.get(value); + } + + @reaction(({ value }) => value) + protected async restoreFile(data: FilePickerProps["value"]) { + if (typeof data === "string") + try { + const blob = await blobOf(data); + const 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; + const { 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..eec8357 --- /dev/null +++ b/registry/new-york/blocks/form-field/example.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { FormField } from "./form-field"; + +export const FormFieldExample = () => { + return ( +
+
+

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..2a1a3f3 --- /dev/null +++ b/registry/new-york/blocks/form-field/form-field.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { FC, TextareaHTMLAttributes } from "react"; + +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 = React.ComponentProps & + Pick, "rows"> & { + label?: string; + options?: SelectOption[]; + multiple?: boolean; + }; + +export const FormField: FC = ({ + className, + style, + label, + placeholder, + id, + name, + options, + multiple, + rows, + onBlur, + ...controlProps +}) => { + const fieldId = name || id || `field-${Math.random().toString(36).substring(7)}`; + + return ( +
+ {label && } + {options ? ( + + ) : rows && rows > 1 ? ( +