diff --git a/app/page.tsx b/app/page.tsx index f4de18e..b17f03d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,4 +1,4 @@ -import { ComponentCard } from "@/components/component-card"; +import { ComponentCard } from "@/components/example/component-card"; import { HelloWorld } from "@/registry/new-york/blocks/hello-world/hello-world"; import { ExampleForm } from "@/registry/new-york/blocks/example-form/example-form"; import PokemonPage from "@/registry/new-york/blocks/complex-component/page"; diff --git a/components/component-card.tsx b/components/example/component-card.tsx similarity index 100% rename from components/component-card.tsx rename to components/example/component-card.tsx diff --git a/components/example/form.tsx b/components/example/form.tsx new file mode 100644 index 0000000..38cd046 --- /dev/null +++ b/components/example/form.tsx @@ -0,0 +1,34 @@ +import { GitRepository } from "mobx-github"; + +import { i18n, topicStore } from "@/models/example"; +import { Field } from "@/registry/new-york/blocks/rest-form/rest-form"; +import { SearchableInput } from "@/registry/new-york/blocks/searchable-input/searchable-input"; + +export const fields: Field[] = [ + { + key: "full_name", + renderLabel: "Repository Name", + required: true, + minLength: 3, + invalidMessage: "Input 3 characters at least", + }, + { key: "homepage", type: "url", renderLabel: "Home Page" }, + { key: "language", renderLabel: "Programming Language" }, + { + key: "topics", + renderLabel: "Topic", + renderInput: ({ topics }) => ( + ({ value, label: value }))} + /> + ), + }, + { key: "stargazers_count", type: "number", renderLabel: "Star Count" }, + { key: "description", renderLabel: "Description", rows: 3 }, +]; diff --git a/components/open-in-v0-button.tsx b/components/example/open-in-v0-button.tsx similarity index 100% rename from components/open-in-v0-button.tsx rename to components/example/open-in-v0-button.tsx diff --git a/models/example.ts b/models/example.ts new file mode 100644 index 0000000..2f4e437 --- /dev/null +++ b/models/example.ts @@ -0,0 +1,49 @@ +import { components, operations } from "@octokit/openapi-types"; +import { githubClient, RepositoryModel } from "mobx-github"; +import { TranslationModel } from "mobx-i18n"; +import { ListModel, Filter } from "mobx-restful"; +import { buildURLData } from "web-utility"; + +export const i18n = new TranslationModel({ + en_US: { + load_more: "Load more", + no_more: "No more", + submit: "Submit", + cancel: "Cancel", + }, +}); + +type Topic = components["schemas"]["topic-search-result-item"]; + +type TopicSearchResponse = + operations["search/topics"]["responses"]["200"]["content"]["application/json"]; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; + +githubClient.use(({ request }, next) => { + if (GITHUB_TOKEN) + request.headers = { + ...request.headers, + Authorization: `Bearer ${GITHUB_TOKEN}`, + }; + return next(); +}); + +class GitHubTopicModel extends ListModel { + baseURI = "search/topics"; + client = githubClient; + + async loadPage(pageIndex: number, pageSize: number, { name }: Filter) { + const { body } = await this.client.get( + `${this.baseURI}?${buildURLData({ + q: name, + page: pageIndex, + per_page: pageSize, + })}` + ); + return { pageData: body!.items, totalCount: body!.total_count }; + } +} + +export const repositoryStore = new RepositoryModel("idea2app"), + topicStore = new GitHubTopicModel(); diff --git a/package.json b/package.json index 648d2be..098ae13 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@babel/plugin-transform-typescript": "^7.28.5", "@babel/preset-react": "^7.28.5", "@eslint/eslintrc": "^3.3.3", + "@octokit/openapi-types": "^27.0.0", "@tailwindcss/postcss": "^4.1.18", "@types/lodash.debounce": "^4.0.9", "@types/node": "^22.19.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d20329..39fbf5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,6 +82,9 @@ importers: '@eslint/eslintrc': specifier: ^3.3.3 version: 3.3.3 + '@octokit/openapi-types': + specifier: ^27.0.0 + version: 27.0.0 '@tailwindcss/postcss': specifier: ^4.1.18 version: 4.1.18 @@ -677,6 +680,9 @@ packages: '@octokit/openapi-types@26.0.0': resolution: {integrity: sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA==} + '@octokit/openapi-types@27.0.0': + resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} + '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -4035,6 +4041,8 @@ snapshots: '@octokit/openapi-types@26.0.0': {} + '@octokit/openapi-types@27.0.0': {} + '@open-draft/deferred-promise@2.2.0': {} '@open-draft/logger@0.3.0': diff --git a/registry.json b/registry.json index 46156dd..bd9e155 100644 --- a/registry.json +++ b/registry.json @@ -222,6 +222,95 @@ "type": "registry:component" } ] + }, + { + "name": "file-uploader", + "type": "registry:component", + "title": "File Uploader", + "description": "A file uploader component with drag-and-drop support for managing multiple files using MobX.", + "registryDependencies": ["file-picker"], + "dependencies": [ + "mobx", + "mobx-react", + "mobx-react-helper", + "mobx-restful" + ], + "files": [ + { + "path": "registry/new-york/blocks/file-uploader/file-uploader.tsx", + "type": "registry:component" + } + ] + }, + { + "name": "rest-form", + "type": "registry:component", + "title": "REST Form", + "description": "A comprehensive form component for CRUD operations with MobX RESTful integration, supporting various field types and validation.", + "registryDependencies": [ + "button", + "label", + "badge-input", + "file-preview", + "file-uploader", + "form-field" + ], + "dependencies": [ + "mobx", + "mobx-i18n", + "mobx-react", + "mobx-react-helper", + "mobx-restful", + "web-utility" + ], + "files": [ + { + "path": "registry/new-york/blocks/rest-form/rest-form.tsx", + "type": "registry:component" + } + ] + }, + { + "name": "rest-form-modal", + "type": "registry:component", + "title": "REST Form Modal", + "description": "A modal wrapper for REST Form component, displaying forms in a dialog for editing data.", + "registryDependencies": ["dialog", "rest-form"], + "dependencies": ["mobx-react", "mobx-restful", "web-utility"], + "files": [ + { + "path": "registry/new-york/blocks/rest-form-modal/rest-form-modal.tsx", + "type": "registry:component" + } + ] + }, + { + "name": "searchable-input", + "type": "registry:component", + "title": "Searchable Input", + "description": "A searchable input component with autocomplete, supporting multiple selections and inline creation of new items.", + "registryDependencies": [ + "button", + "input", + "badge-bar", + "badge-input", + "rest-form-modal", + "scroll-list" + ], + "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/file-uploader/example.tsx b/registry/new-york/blocks/file-uploader/example.tsx new file mode 100644 index 0000000..e9b62ce --- /dev/null +++ b/registry/new-york/blocks/file-uploader/example.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { FileModel, FileUploader } from "./file-uploader"; + +class MyFileModel extends FileModel {} + +const store = new MyFileModel(); + +export const FileUploaderExample = () => ( +
+
+

Single File Upload

+ +

+ Upload a single image file +

+
+ +
+

Multiple Files Upload

+ +

+ Upload multiple image files (drag to reorder) +

+
+ +
+

Uploaded Files

+
+        {JSON.stringify(store.files, null, 2)}
+      
+
+
+); diff --git a/registry/new-york/blocks/file-uploader/file-uploader.tsx b/registry/new-york/blocks/file-uploader/file-uploader.tsx new file mode 100644 index 0000000..257c36b --- /dev/null +++ b/registry/new-york/blocks/file-uploader/file-uploader.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { observable } from "mobx"; +import { observer } from "mobx-react"; +import { FormComponent, FormComponentProps, reaction } from "mobx-react-helper"; +import { BaseModel } from "mobx-restful"; +import { DragEvent } from "react"; + +import { FilePicker } from "../file-picker/file-picker"; + +export abstract class FileModel extends BaseModel { + @observable + accessor files: string[] = []; + + clear() { + super.clear(); + + this.files = []; + } + + /** + * Override this method for Network calling, + * then call `super.upload(fileURL)` to update `this.files` array. + */ + async upload(file: string | Blob) { + if (file instanceof Blob) file = URL.createObjectURL(file); + + const { files } = this; + + if (!files.includes(file)) this.files = [...files, file]; + + return file; + } + + /** + * Override this method for Network calling, + * then call `super.delete(fileURL)` to update `this.files` array. + */ + async delete(file: string) { + const { files } = this; + const index = files.indexOf(file); + + this.files = [...files.slice(0, index), ...files.slice(index + 1)]; + } + + move(sourceIndex: number, targetIndex: number) { + const { files } = this; + const sourceFile = files[sourceIndex], + targetFile = files[targetIndex]; + const frontIndex = Math.min(sourceIndex, targetIndex), + backIndex = Math.max(sourceIndex, targetIndex); + + const front = files.slice(0, frontIndex), + middle = files.slice(frontIndex + 1, backIndex), + back = files.slice(backIndex + 1); + + this.files = + sourceIndex < targetIndex + ? [...front, ...middle, targetFile, sourceFile, ...back] + : [...front, sourceFile, targetFile, ...middle, ...back]; + } +} + +export interface FileUploaderProps extends FormComponentProps { + store: FileModel; +} + +@observer +export class FileUploader extends FormComponent { + static readonly displayName = "FileUploader"; + + @observable + accessor pickIndex: number | undefined; + + componentDidMount() { + super.componentDidMount(); + + const { store } = this.props; + + store.files = this.value || []; + } + + @reaction(({ value }) => value) + protected restoreFile(value: FileUploaderProps["value"]) { + const { store } = this.props; + + store.files = value || []; + } + + handleDrop = (index: number) => (event: DragEvent) => { + event.preventDefault(); + + const { props, pickIndex } = this; + + if (!(pickIndex != null)) return; + + props.store.move(pickIndex, index); + + this.innerValue = props.store.files; + }; + + handleChange = + (oldURI = "") => + async (file: string | File) => { + const { store } = this.props; + + if (oldURI) await store.delete(oldURI); + if (file) await store.upload(file); + + this.innerValue = store.files; + }; + + render() { + const { + className = "", + style, + multiple, + store, + value: _, + defaultValue, + onChange, + ...props + } = this.props; + + const { value } = this; + + return ( +
    event.preventDefault()} + > + {value?.map((file, index) => ( +
  1. (this.pickIndex = index)} + onDrop={this.handleDrop(index)} + > + +
  2. + ))} + {(multiple || !value?.[0]) && ( +
  3. + +
  4. + )} +
+ ); + } +} diff --git a/registry/new-york/blocks/form-field/form-field.tsx b/registry/new-york/blocks/form-field/form-field.tsx index d474d4d..6a95ef8 100644 --- a/registry/new-york/blocks/form-field/form-field.tsx +++ b/registry/new-york/blocks/form-field/form-field.tsx @@ -1,6 +1,6 @@ "use client"; -import { ComponentProps, FC, FocusEvent, TextareaHTMLAttributes } from "react"; +import { ComponentProps, FC, FocusEvent, ReactNode, TextareaHTMLAttributes } from "react"; import { uniqueID } from "web-utility"; import { Input } from "@/components/ui/input"; @@ -14,7 +14,7 @@ export interface SelectOption export type FormFieldProps = ComponentProps & Pick, "rows"> & { - label?: string; + label?: ReactNode; options?: SelectOption[]; multiple?: boolean; }; diff --git a/registry/new-york/blocks/rest-form-modal/example.tsx b/registry/new-york/blocks/rest-form-modal/example.tsx new file mode 100644 index 0000000..8918bc3 --- /dev/null +++ b/registry/new-york/blocks/rest-form-modal/example.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { GitRepository } from "mobx-github"; + +import { fields } from "@/components/example/form"; +import { Button } from "@/components/ui/button"; +import { i18n, repositoryStore } from "@/models/example"; +import { RestFormModal } from "./rest-form-modal"; + +export const RestFormModalExample = () => ( +
+
+

+ Click button to open form modal +

+ +
+ + console.log("Form submitted:", data)} + /> +
+); diff --git a/registry/new-york/blocks/rest-form-modal/rest-form-modal.tsx b/registry/new-york/blocks/rest-form-modal/rest-form-modal.tsx new file mode 100644 index 0000000..cfe4445 --- /dev/null +++ b/registry/new-york/blocks/rest-form-modal/rest-form-modal.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; +import { DataObject, Filter } from "mobx-restful"; +import { isEmpty } from "web-utility"; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { RestForm, RestFormProps } from "../rest-form/rest-form"; + +export const RestFormModal = observer( + = Filter>({ + fields, + store, + translator, + ...props + }: RestFormProps) => { + if (!store) return null; + + const { indexKey, currentOne } = store; + + const editing = !isEmpty(currentOne), + ID = currentOne[indexKey]; + + return ( + store.clearCurrent()}> + + + {ID} + + + + + + ); + } +); +(RestFormModal as FC).displayName = "RestFormModal"; diff --git a/registry/new-york/blocks/rest-form/example.tsx b/registry/new-york/blocks/rest-form/example.tsx new file mode 100644 index 0000000..abbe373 --- /dev/null +++ b/registry/new-york/blocks/rest-form/example.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { fields } from "@/components/example/form"; +import { i18n, repositoryStore } from "@/models/example"; +import { RestForm } from "./rest-form"; + +export const RestFormExample = () => ( +
+
+

Example Form

+ console.log("Form submitted:", data)} + onReset={(data) => console.log("Form reset:", data)} + /> +
+
+); diff --git a/registry/new-york/blocks/rest-form/rest-form.tsx b/registry/new-york/blocks/rest-form/rest-form.tsx new file mode 100644 index 0000000..3675685 --- /dev/null +++ b/registry/new-york/blocks/rest-form/rest-form.tsx @@ -0,0 +1,347 @@ +"use client"; + +import { computed, observable } from "mobx"; +import { TranslationModel } from "mobx-i18n"; +import { observer } from "mobx-react"; +import { ObservedComponent } from "mobx-react-helper"; +import { DataObject, Filter, IDType, ListModel } from "mobx-restful"; +import { + FormEvent, + Fragment, + HTMLAttributes, + InputHTMLAttributes, + ReactNode, +} from "react"; +import { formatDate, formToJSON, isEmpty } from "web-utility"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { BadgeInput } from "../badge-input/badge-input"; +import { FilePreview } from "../file-preview/file-preview"; +import { FileModel, FileUploader } from "../file-uploader/file-uploader"; +import { FormField, FormFieldProps } from "../form-field/form-field"; + +export interface Field + extends Pick< + InputHTMLAttributes, + | "type" + | "readOnly" + | "disabled" + | "required" + | "min" + | "minLength" + | "max" + | "maxLength" + | "step" + | "pattern" + | "multiple" + | "accept" + | "placeholder" + >, + Pick { + key?: keyof T; + renderLabel?: ReactNode | ((key: keyof T) => ReactNode); + renderInput?: (data: T, meta: Field) => ReactNode; + uploader?: FileModel; + contentEditable?: boolean; + validMessage?: ReactNode; + invalidMessage?: ReactNode; +} + +export interface FieldBoxProps + extends HTMLAttributes, + Pick, "renderLabel" | `${"" | "in"}validMessage`> { + name: Field["key"]; +} + +export interface RestFormProps< + D extends DataObject, + F extends Filter = Filter +> extends Omit, "id" | "onSubmit" | "onReset"> { + id?: IDType; + fields: Field[]; + store?: ListModel; + translator: TranslationModel; + size?: "default" | "sm" | "lg"; + onSubmit?: (data: D) => any; + onReset?: (data: Partial) => any; +} + +@observer +export class RestForm< + D extends DataObject, + F extends Filter = Filter +> extends ObservedComponent> { + static readonly displayName = "RestForm"; + + static dateValueOf = ( + { type, step = "60" }: Field, + raw: D[keyof D] + ) => + isEmpty(raw) + ? raw + : type === "month" + ? formatDate(raw, "YYYY-MM") + : type === "date" + ? formatDate(raw, "YYYY-MM-DD") + : type === "datetime-local" + ? formatDate(raw, `YYYY-MM-DDTHH:mm${+step < 60 ? ":ss" : ""}`) + : raw; + + static FieldBox = ({ + name, + renderLabel, + validMessage, + invalidMessage, + children, + className, + ...props + }: FieldBoxProps) => ( +
+ + {children} + {validMessage && ( +
{validMessage}
+ )} + {invalidMessage && ( +
{invalidMessage}
+ )} +
+ ); + + @observable + accessor validated = false; + + componentDidMount() { + const { id, store } = this.props; + + if (id) store?.getOne(id); + } + + handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + event.stopPropagation(); + + const form = event.currentTarget; + const valid = form.checkValidity(); + + this.validated = true; + + if (valid) { + const { id, store, onSubmit } = this.props; + let data = formToJSON(form); + + if (store) data = await store.updateOne(data, id); + + onSubmit?.(data); + + store?.clearCurrent(); + } + this.validated = false; + }; + + handleReset = ({ currentTarget }: FormEvent) => { + const { onReset, store } = this.props; + + onReset?.(formToJSON(currentTarget)); + store?.clearCurrent(); + }; + + @computed + get fields(): Field[] { + const { fields } = this.observedProps; + + return fields.map(({ renderInput, ...meta }) => ({ + ...meta, + renderInput: + renderInput ?? + (meta.type === "file" + ? this.renderFile(meta) + : (meta.type === "radio" || meta.type === "checkbox") && meta.options + ? this.renderCheckGroup(meta) + : !meta.options && meta.multiple + ? this.renderMultipleInput(meta) + : meta.key && + this.renderField( + meta, + meta.rows && !meta.options ? { rows: meta.rows } : {} + )), + })); + } + + @computed + get readOnly() { + return this.fields.every(({ readOnly, disabled }) => readOnly || disabled); + } + + @computed + get customValidation() { + return this.fields.some( + ({ validMessage, invalidMessage }) => validMessage || invalidMessage + ); + } + + @computed + get fieldReady() { + const { id, store } = this.observedProps; + + return !id || !store || store.downloading < 1; + } + + renderFile = + ({ key, type, required, multiple, accept, uploader, ...meta }: Field) => + ({ [key!]: paths }: D) => { + const value = ( + (Array.isArray(paths) ? paths : [paths]) as string[] + ).filter(Boolean); + + return ( + + {uploader ? ( + + ) : ( + value.map((path) => ) + )} + + ); + }; + + renderCheckGroup = + ({ key, type, options, ...meta }: Field) => + (data: D) => + ( + +
+ {this.fieldReady && + options?.map(({ value, label = value }) => ( +
+ + +
+ ))} +
+
+ ); + + renderMultipleInput = + ({ key, type, ...meta }: Field) => + ({ [key!]: value }: D) => + ( + + {this.fieldReady && ( + + )} + + ); + + renderField = ( + { + key, + type, + step, + renderLabel, + renderInput, + validMessage, + invalidMessage, + ...meta + }: Field, + props: Partial = {} + ) => { + const label = + typeof renderLabel === "function" + ? key + ? renderLabel(key) + : "" + : renderLabel || (key as string); + + return ({ [key!]: value }: D) => ( +
+ {this.fieldReady && ( + + )} + {validMessage && ( +
{validMessage}
+ )} + {invalidMessage && ( +
{invalidMessage}
+ )} +
+ ); + }; + + render() { + const { fields, readOnly, customValidation } = this, + { id, className, size, store, translator, ...props } = this.props; + const { downloading, uploading, currentOne = {} as D } = store || {}, + { t } = translator; + const loading = downloading! > 0 || uploading! > 0; + + return ( +
+ {fields.map(({ renderInput, ...meta }) => ( + + {renderInput?.(currentOne, meta)} + + ))} + {!readOnly && ( +
+ + +
+ )} +
+ ); + } +} diff --git a/registry/new-york/blocks/searchable-input/example.tsx b/registry/new-york/blocks/searchable-input/example.tsx new file mode 100644 index 0000000..0b53251 --- /dev/null +++ b/registry/new-york/blocks/searchable-input/example.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useState } from "react"; + +import { i18n, topicStore } from "@/models/example"; +import { OptionData, SearchableInput } from "./searchable-input"; + +export const SearchableInputExample = () => { + const [selectedTopics, setSelectedTopics] = useState([]); + + return ( +
+
+

Searchable Input

+ setSelectedTopics(value)} + /> +

+ Type to search for topics +

+
+ +
+

Selected Topics

+ +
+          {JSON.stringify(selectedTopics, null, 2)}
+        
+
+
+ ); +}; diff --git a/registry/new-york/blocks/searchable-input/searchable-input.tsx b/registry/new-york/blocks/searchable-input/searchable-input.tsx new file mode 100644 index 0000000..86cb198 --- /dev/null +++ b/registry/new-york/blocks/searchable-input/searchable-input.tsx @@ -0,0 +1,207 @@ +"use client"; + +import debounce from "lodash.debounce"; +import { observable } from "mobx"; +import { observer } from "mobx-react"; +import { FormComponent, FormComponentProps } from "mobx-react-helper"; +import { DataObject, Filter } from "mobx-restful"; +import { FocusEvent } from "react"; +import { Second } from "web-utility"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { BadgeBar } from "../badge-bar/badge-bar"; +import { TextInputType } from "../badge-input/badge-input"; +import { RestFormProps } from "../rest-form/rest-form"; +import { RestFormModal } from "../rest-form-modal/rest-form-modal"; +import { ScrollList, ScrollListProps } from "../scroll-list/scroll-list"; + +export type OptionData = Record<"label" | "value", string>; + +export type SearchableInputProps< + D extends DataObject, + F extends Filter = Filter +> = Omit< + ScrollListProps, + "id" | "defaultValue" | "onChange" | "defaultData" | "renderList" +> & + FormComponentProps & + Omit, "defaultValue" | "onChange" | "fields"> & { + translator: RestFormProps["translator"] & + ScrollListProps["translator"]; + fields?: RestFormProps["fields"]; + labelKey: keyof D; + valueKey: keyof D; + renderList?: ScrollListProps["renderList"]; + type?: TextInputType; + multiple?: boolean; + }; + +@observer +export class SearchableInput< + D extends DataObject, + F extends Filter = Filter +> extends FormComponent> { + static readonly displayName = "SearchableInput"; + + @observable + accessor filter = this.props.filter; + + @observable + accessor listShown = false; + + search = debounce(async (value: string) => { + const { store, labelKey } = this.props; + + value = value.trim(); + + this.filter = { ...this.filter, [labelKey]: value || undefined } as F; + + if (store.downloading < 1) + if (value) { + if (!this.listShown) this.listShown = true; + else await store.getList(this.filter, 1); + } else { + this.listShown = false; + store.clearList(); + } + }, Second); + + add = (label: string, value: string) => { + const selectedOptions = this.value || []; + + if (selectedOptions.find(({ value: v }) => v === value)) return; + + this.innerValue = [...selectedOptions, { label, value }]; + + if (!this.props.multiple) this.listShown = false; + }; + + delete = (index: number) => + (this.innerValue = [ + ...this.value!.slice(0, index), + ...this.value!.slice(index + 1), + ]); + + handleBlur = ({ target, relatedTarget }: FocusEvent) => { + if (target.parentElement !== relatedTarget?.parentElement) + this.listShown = false; + }; + + renderList: ScrollListProps["renderList"] = (allItems) => + allItems[0] ? ( +
+ {allItems.map((data) => { + const { labelKey, valueKey } = this.observedProps; + + const label = data[labelKey], + value = data[valueKey]; + + return ( + + ); + })} +
+ ) : ( + this.observedProps.store.downloading > 0 && ( +
+
+
+ ) + ); + + renderOverlay() { + const { filter } = this; + const { + translator, + fields, + store, + labelKey, + renderList = this.renderList, + } = this.props; + + const keyword = filter?.[labelKey as keyof F] as string; + + const needNew = !store.allItems.some( + ({ [labelKey]: label }) => label === keyword + ); + + return ( +
+ {needNew && fields?.[0] && ( + + )} + +
+ ); + } + + render() { + const { value, listShown } = this, + { + translator, + fields, + store, + labelKey, + valueKey, + type = "search", + name, + required, + readOnly, + disabled, + placeholder, + } = this.props; + + return ( +
+
+ ({ text: label }))} + onDelete={({}, index) => this.delete(index)} + /> + value))} + /> + this.search(value)} + /> +
+ + {listShown && this.renderOverlay()} + + {fields?.[0] && ( + { + this.add(label, value); + this.listShown = false; + }} + /> + )} +
+ ); + } +}