import { useCallback, useEffect, useRef, useState, type FormEvent, type RefObject } from 'react' import { createPortal } from 'react-dom' import { Plus } from 'lucide-react' import type { App } from '../../plugin/app' import { createTypeSchemaDraft, serializeTypeSchema } from '../../types-records/type-schema' import { isTypeName } from '../../types-records/type-name' import './root-type-creation.css' function validationError(name: string): string | null { if (!name) return 'Enter a Type name.' if (!isTypeName(name)) return 'Use a lowercase name starting with a letter; letters, numbers, hyphens, and underscores are allowed.' return null } function duplicateError(name: string): string { return `A Type named "${name}" already exists.` } async function createRootType(app: App, name: string) { await app.vault.createFolder('types') const path = `types/${name}.json` if (app.vault.getAbstractFileByPath(path)) throw new Error(duplicateError(name)) let file try { file = await app.vault.create(path, serializeTypeSchema(createTypeSchemaDraft(name))) } catch (error) { // Vault.create is the atomic boundary that also rejects a concurrent creator. if (error instanceof Error && /already exists/i.test(error.message)) throw Object.assign(new Error(duplicateError(name)), { cause: error }) throw error } return file } export function RootTypeCreationAction({ app }: { readonly app: App }) { const [isOpen, setIsOpen] = useState(false) const triggerRef = useRef(null) return <> setIsOpen(false)} /> } function RootTypeCreationModal({ app, isOpen, triggerRef, onClose }: { app: App; isOpen: boolean; triggerRef: RefObject; onClose: () => void }) { const [name, setName] = useState('') const [error, setError] = useState(null) const [isSubmitting, setIsSubmitting] = useState(false) const [created, setCreated] = useState(false) const inputRef = useRef(null) const dialogRef = useRef(null) const mountedRef = useRef(true) const submittingRef = useRef(false) const generationRef = useRef(0) const wasOpenRef = useRef(false) const trimmedName = name.trim() const inlineError = validationError(trimmedName) const visibleError = trimmedName ? (error ?? inlineError) : error const requestClose = useCallback(() => { if (!submittingRef.current) onClose() }, [onClose]) useEffect(() => { mountedRef.current = true return () => { mountedRef.current = false; generationRef.current += 1 } }, []) useEffect(() => { if (!isOpen) { setName('') setError(null) setIsSubmitting(false) setCreated(false) submittingRef.current = false if (wasOpenRef.current) { wasOpenRef.current = false triggerRef.current?.focus() } return } wasOpenRef.current = true const generation = ++generationRef.current const timer = setTimeout(() => inputRef.current?.focus(), 0) return () => { clearTimeout(timer); if (generation === generationRef.current) generationRef.current += 1 } }, [isOpen, triggerRef]) useEffect(() => { if (!isOpen) return const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault() event.stopPropagation() requestClose() return } if (event.key !== 'Tab' || !dialogRef.current) return const focusable = Array.from(dialogRef.current.querySelectorAll('button:not(:disabled), input:not(:disabled)')) if (!focusable.length) { event.preventDefault(); dialogRef.current.focus(); return } const first = focusable[0]! const last = focusable[focusable.length - 1]! if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() } } document.addEventListener('keydown', onKeyDown) return () => document.removeEventListener('keydown', onKeyDown) }, [isOpen, requestClose]) if (!isOpen) return null // eslint-disable-next-line sonarjs/cognitive-complexity const submit = async (event: FormEvent) => { event.preventDefault() if (submittingRef.current || inlineError || created) { setError(inlineError); return } const generation = generationRef.current submittingRef.current = true setIsSubmitting(true) setError(null) try { const file = await createRootType(app, trimmedName) if (!mountedRef.current || generation !== generationRef.current) return setCreated(true) try { await app.workspace.openFile(file) } catch (reason) { if (mountedRef.current && generation === generationRef.current) setError(`Type created, but it could not be opened: ${reason instanceof Error ? reason.message : 'unknown error'}`) return } if (mountedRef.current && generation === generationRef.current) onClose() } catch (reason) { if (mountedRef.current && generation === generationRef.current) setError(reason instanceof Error ? reason.message : 'Could not create Type.') } finally { if (mountedRef.current && generation === generationRef.current) { setIsSubmitting(false) submittingRef.current = false } } } let submitLabel = 'Create Type' if (isSubmitting) submitLabel = 'Creating...' else if (created) submitLabel = 'Type created' return createPortal(
{ if (event.target === event.currentTarget) requestClose() }}>
event.stopPropagation()}>

New Type

{ void submit(event) }}> { setName(event.target.value); setError(null) }} disabled={isSubmitting} aria-invalid={Boolean(visibleError)} aria-describedby={visibleError ? 'root-type-name-error' : undefined} />

Lowercase, starting with a letter. Use letters, numbers, hyphens, or underscores.

{visibleError ? : null}
, document.body, ) }