Skip to content

Commit 88e80d5

Browse files
CopilotTechQuery
andcommitted
Implement 6 new Shadcn UI components with examples
Co-authored-by: TechQuery <19969570+TechQuery@users.noreply.github.com>
1 parent 117e2c7 commit 88e80d5

13 files changed

Lines changed: 1172 additions & 0 deletions

File tree

registry.json

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,118 @@
167167
"type": "registry:component"
168168
}
169169
]
170+
},
171+
{
172+
"name": "array-field",
173+
"type": "registry:component",
174+
"title": "Array Field",
175+
"description": "A dynamic array field component with add/remove functionality for form arrays.",
176+
"registryDependencies": ["button"],
177+
"dependencies": [
178+
"lucide-react",
179+
"mobx",
180+
"mobx-react",
181+
"mobx-react-helper",
182+
"mobx-restful",
183+
"web-utility"
184+
],
185+
"files": [
186+
{
187+
"path": "registry/new-york/blocks/array-field/array-field.tsx",
188+
"type": "registry:component"
189+
}
190+
]
191+
},
192+
{
193+
"name": "badge-input",
194+
"type": "registry:component",
195+
"title": "Badge Input",
196+
"description": "An input component that displays values as removable badges, supporting multiple entries.",
197+
"registryDependencies": ["badge"],
198+
"dependencies": [
199+
"mobx",
200+
"mobx-react",
201+
"mobx-react-helper",
202+
"web-utility"
203+
],
204+
"files": [
205+
{
206+
"path": "registry/new-york/blocks/badge-input/badge-input.tsx",
207+
"type": "registry:component"
208+
}
209+
]
210+
},
211+
{
212+
"name": "range-input",
213+
"type": "registry:component",
214+
"title": "Range Input",
215+
"description": "A range slider input with optional custom icon display for each step.",
216+
"dependencies": [
217+
"lucide-react",
218+
"mobx",
219+
"mobx-react",
220+
"mobx-react-helper"
221+
],
222+
"files": [
223+
{
224+
"path": "registry/new-york/blocks/range-input/range-input.tsx",
225+
"type": "registry:component"
226+
}
227+
]
228+
},
229+
{
230+
"name": "file-picker",
231+
"type": "registry:component",
232+
"title": "File Picker",
233+
"description": "A file picker component with preview and remove functionality.",
234+
"registryDependencies": ["button"],
235+
"dependencies": [
236+
"lucide-react",
237+
"mobx",
238+
"mobx-react",
239+
"mobx-react-helper",
240+
"web-utility"
241+
],
242+
"files": [
243+
{
244+
"path": "registry/new-york/blocks/file-picker/file-picker.tsx",
245+
"type": "registry:component"
246+
}
247+
]
248+
},
249+
{
250+
"name": "form-field",
251+
"type": "registry:component",
252+
"title": "Form Field",
253+
"description": "A unified form field component supporting input, textarea, and select elements with labels.",
254+
"registryDependencies": ["input", "label"],
255+
"files": [
256+
{
257+
"path": "registry/new-york/blocks/form-field/form-field.tsx",
258+
"type": "registry:component"
259+
}
260+
]
261+
},
262+
{
263+
"name": "searchable-input",
264+
"type": "registry:component",
265+
"title": "Searchable Input",
266+
"description": "A searchable select input with badge display, supporting single or multiple selection.",
267+
"registryDependencies": ["button", "input"],
268+
"dependencies": [
269+
"lodash.debounce",
270+
"mobx",
271+
"mobx-react",
272+
"mobx-react-helper",
273+
"mobx-restful",
274+
"web-utility"
275+
],
276+
"files": [
277+
{
278+
"path": "registry/new-york/blocks/searchable-input/searchable-input.tsx",
279+
"type": "registry:component"
280+
}
281+
]
170282
}
171283
]
172284
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"use client";
2+
3+
import { toJS } from "mobx";
4+
import { observer } from "mobx-react";
5+
import { FormComponent, FormComponentProps } from "mobx-react-helper";
6+
import { DataObject } from "mobx-restful";
7+
import { ChangeEvent, HTMLAttributes, ReactNode } from "react";
8+
import { Plus, Minus } from "lucide-react";
9+
import { formToJSON, isEmpty } from "web-utility";
10+
11+
import { Button } from "@/components/ui/button";
12+
import { cn } from "@/lib/utils";
13+
14+
export type ArrayFieldProps<T extends DataObject = DataObject> = Pick<
15+
HTMLAttributes<HTMLFieldSetElement>,
16+
"className" | "style"
17+
> &
18+
FormComponentProps<T[]> & {
19+
renderItem: (item: T, index: number) => ReactNode;
20+
};
21+
22+
@observer
23+
export class ArrayField<
24+
T extends DataObject = DataObject
25+
> extends FormComponent<ArrayFieldProps<T>> {
26+
static displayName = "ArrayField";
27+
28+
componentDidMount() {
29+
super.componentDidMount();
30+
31+
if (isEmpty(this.value)) this.add();
32+
}
33+
34+
add = () => (this.innerValue = [...(this.innerValue || []), {} as T]);
35+
36+
remove = (index: number) =>
37+
(this.innerValue = this.innerValue?.filter((_, i) => i !== index));
38+
39+
handleChange =
40+
(index: number) =>
41+
({ currentTarget }: ChangeEvent<EventTarget>) => {
42+
const item = formToJSON<T>(currentTarget as HTMLFieldSetElement);
43+
const { innerValue } = this;
44+
45+
const list = [
46+
...innerValue!.slice(0, index),
47+
item,
48+
...innerValue!.slice(index + 1),
49+
].map((item) => toJS(item));
50+
this.props.onChange?.(list);
51+
};
52+
53+
handleUpdate =
54+
(index: number) =>
55+
({ currentTarget }: ChangeEvent<EventTarget>) =>
56+
(this.innerValue![index] = formToJSON<T>(
57+
currentTarget as HTMLFieldSetElement
58+
));
59+
60+
render() {
61+
const { className = "", style, name, renderItem } = this.props;
62+
63+
return (
64+
<>
65+
{this.value?.map((item, index, { length }) => (
66+
<fieldset
67+
key={JSON.stringify(item)}
68+
className={cn("flex items-center my-2 gap-2", className)}
69+
{...{ style, name }}
70+
onChange={this.handleChange(index)}
71+
onBlur={this.handleUpdate(index)}
72+
>
73+
<div className="flex-1">{renderItem(item, index)}</div>
74+
<div className="flex gap-1">
75+
<Button
76+
type="button"
77+
size="sm"
78+
variant="outline"
79+
onClick={this.add}
80+
>
81+
<Plus className="h-4 w-4" />
82+
</Button>
83+
<Button
84+
type="button"
85+
size="sm"
86+
variant="destructive"
87+
disabled={length < 2}
88+
onClick={() => this.remove(index)}
89+
>
90+
<Minus className="h-4 w-4" />
91+
</Button>
92+
</div>
93+
</fieldset>
94+
))}
95+
</>
96+
);
97+
}
98+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { DataObject } from "mobx-restful";
5+
6+
import { Input } from "@/components/ui/input";
7+
import { ArrayField } from "./array-field";
8+
9+
interface TodoItem extends DataObject {
10+
title: string;
11+
completed?: boolean;
12+
}
13+
14+
export const ArrayFieldExample = () => {
15+
const [items, setItems] = useState<TodoItem[]>([
16+
{ title: "First task" },
17+
{ title: "Second task" },
18+
]);
19+
20+
return (
21+
<div className="w-full space-y-8">
22+
<div>
23+
<h3 className="text-lg font-semibold mb-4">Array Field with Inputs</h3>
24+
<ArrayField<TodoItem>
25+
value={items}
26+
onChange={setItems}
27+
renderItem={(item, index) => (
28+
<Input
29+
name="title"
30+
defaultValue={item.title}
31+
placeholder={`Task ${index + 1}`}
32+
/>
33+
)}
34+
/>
35+
</div>
36+
37+
<div>
38+
<h3 className="text-lg font-semibold mb-4">Current Values</h3>
39+
<pre className="p-4 bg-muted rounded-md">
40+
{JSON.stringify(items, null, 2)}
41+
</pre>
42+
</div>
43+
</div>
44+
);
45+
};
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"use client";
2+
3+
import { observer } from "mobx-react";
4+
import { FormComponent, FormComponentProps } from "mobx-react-helper";
5+
import { KeyboardEvent } from "react";
6+
import { isEmpty } from "web-utility";
7+
8+
import { cn } from "@/lib/utils";
9+
import { BadgeBar } from "@/registry/new-york/blocks/badge-bar/badge-bar";
10+
11+
export const TextInputTypes = ["text", "number", "tel", "email", "url"] as const;
12+
13+
export interface BadgeInputProps extends FormComponentProps<string[]> {
14+
type?: (typeof TextInputTypes)[number];
15+
}
16+
17+
@observer
18+
export class BadgeInput extends FormComponent<BadgeInputProps> {
19+
static readonly displayName = "BadgeInput";
20+
21+
static match(type: string): type is BadgeInputProps["type"] {
22+
return TextInputTypes.includes(type as BadgeInputProps["type"]);
23+
}
24+
25+
handleInput = (event: KeyboardEvent<HTMLInputElement>) => {
26+
const input = event.currentTarget;
27+
const { value } = input;
28+
const innerValue = this.innerValue || [];
29+
30+
switch (event.key) {
31+
case "Enter": {
32+
event.preventDefault();
33+
input.value = "";
34+
35+
if (value) this.innerValue = [...innerValue, value];
36+
37+
break;
38+
}
39+
case "Backspace": {
40+
if (!value) this.innerValue = innerValue.slice(0, -1);
41+
}
42+
}
43+
};
44+
45+
delete(index: number) {
46+
const { innerValue } = this;
47+
48+
this.innerValue = [
49+
...innerValue.slice(0, index),
50+
...innerValue.slice(index + 1),
51+
];
52+
}
53+
54+
render() {
55+
const { value } = this;
56+
const { className = "", style, type, name, required, placeholder } =
57+
this.props;
58+
59+
return (
60+
<div
61+
className={cn(
62+
"flex min-h-9 w-full flex-wrap items-center gap-2 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
63+
className
64+
)}
65+
style={style}
66+
>
67+
<BadgeBar
68+
list={(value || []).map((text) => ({ text }))}
69+
onDelete={({}, index) => this.delete(index)}
70+
/>
71+
<input
72+
className="flex-1 border-0 bg-transparent outline-none min-w-[80px]"
73+
type={type}
74+
required={isEmpty(value) ? required : undefined}
75+
placeholder={placeholder}
76+
onKeyDown={this.handleInput}
77+
/>
78+
<input type="hidden" name={name} value={JSON.stringify(value)} />
79+
</div>
80+
);
81+
}
82+
}

0 commit comments

Comments
 (0)