From cdeeb8201eceade035aab5df781ebc5f304cb182 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Sun, 18 Oct 2026 10:45:46 +0100 Subject: [PATCH] Fix component bugs across hooks, elements, form, layout, editors and utils - Hooks: per-instance timers in useReady; cleanup for useIntersectionObserver; useWebSocket no longer reconnects after unmount/refresh, drains the queue and dispatches non-JSON messages - Modal/Popover/Dropdown: onClose only on close, Escape to close, no stray DOM props, hooks before early return, scrollbar-aware positioning, top-left/top-right dropdown positions - Elements: Toast/Search/StarRating per-instance timers, Pagination labels and empty state, Breadcrumbs titles, LinkList dividers/props, segment-wise active-link matching (new utils/link-matches-path), Table object cells - Form: Select with a default option is changeable, Input clear notifies controlled parents and copy works on textareas, Checkbox random label, NumberInputButtons runaway interval, upload prop/spread fixes - Layout: Button disabled while loading, Stack gap and Span truncate via inline styles, Img fallbackImageSrc, className/JSDoc fixes - Editors/MDX/AI: TinyMCE id/cleanup/stale handler/duplicate scripts, AceEditor debounce, markdown heights and frontmatter regex, last assistant message, AIPromptBlock prompt ref - Utils: fetchApi headers/FormData/fetchOptions/no mutation, base64 file names and read errors, slugify underscore trim, serializeQuery zero values - Tests for fetchApi, link matching, slugify and serializeQuery - AGENTS.md updated for the package layout Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 147 +++--- src/(functions)/popver/grab-popover-styles.ts | 13 +- src/(partials)/ModalComponent.tsx | 24 +- src/(partials)/PopoverComponent.tsx | 11 +- src/composites/docs/TWUIDocsLink.tsx | 20 +- src/composites/docs/TWUIDocsRightAside.tsx | 2 + src/editors/AceEditor.tsx | 8 +- src/editors/TinyMCE/index.tsx | 44 +- src/editors/TinyMCE/useTinyMCE.tsx | 17 +- src/elements/Breadcrumbs.tsx | 459 +++++++++--------- src/elements/Dropdown.tsx | 14 + src/elements/HeaderNav.tsx | 17 +- src/elements/LinkList.tsx | 16 +- src/elements/Modal.tsx | 8 +- src/elements/Pagination.tsx | 22 +- src/elements/RemoteCodeBlock.tsx | 106 ++-- src/elements/Search.tsx | 15 +- src/elements/StarRating.tsx | 13 +- src/elements/Table.tsx | 16 +- src/elements/Toast.tsx | 14 +- src/elements/ai/AIPromptActionSection.tsx | 1 + src/elements/ai/AIPromptBlock.tsx | 3 +- src/elements/ai/AIPromptBlockInputSection.tsx | 8 +- src/elements/ai/AIVoiceTranscription.tsx | 7 +- src/form/Checkbox.tsx | 4 +- src/form/FileUpload.tsx | 23 +- src/form/ImageUpload.tsx | 18 +- src/form/Input/NumberInputButtons.tsx | 7 + src/form/Input/index.tsx | 65 +-- src/form/SearchSelect.tsx | 31 +- src/form/Select.tsx | 41 +- src/hooks/useCustomEventDispatch.tsx | 2 +- src/hooks/useCustomEventListener.tsx | 2 +- src/hooks/useIntersectionObserver.tsx | 15 +- src/hooks/useReady.tsx | 12 +- src/hooks/useWebSocket.tsx | 39 +- src/layout/ArrowedLink.tsx | 12 +- src/layout/Button.tsx | 2 + src/layout/IconLink.tsx | 4 +- src/layout/Img.tsx | 32 +- src/layout/Main.tsx | 2 +- src/layout/Span.tsx | 13 +- src/layout/Stack.tsx | 18 +- src/mdx/markdown/MarkdownEditor.tsx | 7 +- src/mdx/markdown/MarkdownEditorComponent.tsx | 3 +- .../MarkdownEditorPreviewComponent.tsx | 10 +- src/utils/fetch/fetchApi.test.ts | 82 ++++ src/utils/fetch/fetchApi.ts | 270 ++++++----- src/utils/form/fileInputToBase64.ts | 6 +- src/utils/form/imageInputToBase64.ts | 2 +- src/utils/link-matches-path.test.ts | 63 +++ src/utils/link-matches-path.ts | 45 ++ src/utils/serialize-query.test.ts | 5 + src/utils/serialize-query.ts | 2 +- src/utils/slugify.test.ts | 4 + src/utils/slugify.ts | 4 +- 56 files changed, 1128 insertions(+), 722 deletions(-) create mode 100644 src/utils/fetch/fetchApi.test.ts create mode 100644 src/utils/link-matches-path.test.ts create mode 100644 src/utils/link-matches-path.ts diff --git a/AGENTS.md b/AGENTS.md index 1ee6890..c9fdd4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,106 +1,71 @@ +# Agent Instructions -Default to using Bun instead of Node.js. +## Stack -- Use `bun ` instead of `node ` or `ts-node ` -- Use `bun test` instead of `jest` or `vitest` -- Use `bun build ` instead of `webpack` or `esbuild` -- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` -- Use `bun run - - -``` - -With the following `frontend.tsx`: - -```tsx#frontend.tsx -import React from "react"; - -// import .css files directly and it works -import './index.css'; - -import { createRoot } from "react-dom/client"; - -const root = createRoot(document.body); - -export default function Frontend() { - return

Hello, world!

; -} - -root.render(); -``` - -Then, run index.ts - -```sh -bun --hot ./index.ts -``` - -For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`. +1. `bun run typecheck` and `bun test src` pass. +2. For packaging/exports changes: `bun run compile`, then `bun pm pack` and install the tarball into a scratch project. Check that root and deep imports typecheck with `skipLibCheck: false`, and that `@import "@moduletrace/twui/base.css"` produces the component classes when run through the Tailwind CLI. diff --git a/src/(functions)/popver/grab-popover-styles.ts b/src/(functions)/popver/grab-popover-styles.ts index 833193e..e8c86d9 100644 --- a/src/(functions)/popver/grab-popover-styles.ts +++ b/src/(functions)/popver/grab-popover-styles.ts @@ -14,6 +14,10 @@ export default function twuiGrabPopoverStyles({ }: Params): React.CSSProperties { if (!targetElRef.current) return {}; const rect = targetElRef.current.getBoundingClientRect(); + // Absolute `right`/`bottom` resolve against the initial containing block, + // which excludes scrollbars (unlike window.innerWidth/innerHeight) + const viewportWidth = document.documentElement.clientWidth; + const viewportHeight = document.documentElement.clientHeight; const targetElCurrStyles = window.getComputedStyle(targetElRef.current); @@ -31,7 +35,7 @@ export default function twuiGrabPopoverStyles({ }; const defaultTopStyleStyle: React.CSSProperties = { - bottom: window.innerHeight - (rect.top + window.scrollY) + 8, + bottom: viewportHeight - (rect.top + window.scrollY) + 8, left: rect.left + window.scrollX + rect.width / 2, transform: "translateX(-50%)", }; @@ -43,7 +47,7 @@ export default function twuiGrabPopoverStyles({ popoverStyle, _.omit(defaultBottomStyle, ["transform"]), { - left: rect.left, + left: rect.left + window.scrollX, } as React.CSSProperties ); } else if (position === "bottom-right") { @@ -52,7 +56,7 @@ export default function twuiGrabPopoverStyles({ _.omit(defaultBottomStyle, ["left", "transform"]), { right: - window.innerWidth - + viewportWidth - (rect.left + window.scrollX) - rect.width - targetElRightPadding, @@ -66,8 +70,7 @@ export default function twuiGrabPopoverStyles({ popoverStyle.transform = "translateY(-50%)"; } else if (position === "left") { popoverStyle.top = rect.top + window.scrollY + rect.height / 2; - popoverStyle.right = - window.innerWidth - (rect.left + window.scrollX) + 8; + popoverStyle.right = viewportWidth - (rect.left + window.scrollX) + 8; popoverStyle.transform = "translateY(-50%)"; } diff --git a/src/(partials)/ModalComponent.tsx b/src/(partials)/ModalComponent.tsx index 4c5b31b..1f72406 100644 --- a/src/(partials)/ModalComponent.tsx +++ b/src/(partials)/ModalComponent.tsx @@ -21,6 +21,17 @@ export default function ModalComponent({ no_cancel_button, ...props }: Props) { + React.useEffect(() => { + if (!open) return; + + const closeOnEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + + document.addEventListener("keydown", closeOnEscape); + return () => document.removeEventListener("keydown", closeOnEscape); + }, [open]); + if (!open) return null; return ReactDOM.createPortal( @@ -43,7 +54,18 @@ export default function ModalComponent({ }} > { @@ -68,7 +66,14 @@ export default function PopoverComponent({ return ReactDOM.createPortal( , @@ -44,16 +45,23 @@ export default function TWUIDocsLink({ React.useEffect(() => { if (typeof window !== "undefined") { - const basePathMatch = window.location.pathname.includes( - docLink.href, - ); + const basePathMatch = twuiLinkMatchesPath({ + href: docLink.href, + pathname: window.location.pathname, + }); const isStrictMatch = Boolean( linkRef.current?.getAttribute("data-strict"), ); if (strict || isStrictMatch) { - setIsActive(window.location.pathname === docLink.href); + setIsActive( + twuiLinkMatchesPath({ + href: docLink.href, + pathname: window.location.pathname, + strict: true, + }), + ); } else { setIsActive(basePathMatch); } @@ -66,8 +74,8 @@ export default function TWUIDocsLink({ return ( {child && } @@ -105,11 +113,11 @@ export default function TWUIDocsLink({ {docLink.children && expand && ( {docLink.children.map((link, index) => ( { observer.observe(headerHref); }); + + return () => observer.disconnect(); }, [links]); React.useEffect(() => { diff --git a/src/editors/AceEditor.tsx b/src/editors/AceEditor.tsx index ea00194..347f2e2 100644 --- a/src/editors/AceEditor.tsx +++ b/src/editors/AceEditor.tsx @@ -24,8 +24,6 @@ export type AceEditorComponentType = { showLabel?: boolean; }; -let timeout: any; - /** * # Powerful Ace Editor * @note **NOTE** head scripts required @@ -51,6 +49,7 @@ export default function AceEditor({ try { const editorElementRef = React.useRef(undefined); const editorRefInstance = React.useRef(undefined); + const changeTimeoutRef = React.useRef(null); const [refresh, setRefresh] = React.useState(0); const [darkMode, setDarkMode] = React.useState(false); @@ -106,9 +105,9 @@ export default function AceEditor({ editor.getSession().on("change", function (e) { if (onChange) { - clearTimeout(timeout); + window.clearTimeout(changeTimeoutRef.current); - setTimeout(() => { + changeTimeoutRef.current = setTimeout(() => { try { onChange( editor.getValue(), @@ -123,6 +122,7 @@ export default function AceEditor({ if (editorRef) editorRef.current = editor; return function () { + window.clearTimeout(changeTimeoutRef.current); editor.destroy(); }; }, [refresh, darkMode, ready, mode]); diff --git a/src/editors/TinyMCE/index.tsx b/src/editors/TinyMCE/index.tsx index b0b8b7c..4fc5037 100644 --- a/src/editors/TinyMCE/index.tsx +++ b/src/editors/TinyMCE/index.tsx @@ -52,8 +52,12 @@ export default function TinyMCEEditor({ const { tinyMCE } = useTinyMCE({ base_url }); const editorComponentRef = React.useRef(null); + const internalEditorRef = React.useRef(null); const editorRef: RefObject = - passedEditorRef || React.useRef(null); + passedEditorRef || internalEditorRef; + const changeHandlerRef = React.useRef(changeHandler); + changeHandlerRef.current = changeHandler; + const valueTimeoutRef = React.useRef(null); const EDITOR_VALUE_CHANGE_TIMEOUT = 500; @@ -79,21 +83,21 @@ export default function TinyMCEEditor({ }, 200); }, [tinyMCE]); - let valueTimeout: any; - - const id = crypto.randomUUID(); + // Stable across renders (and works outside secure contexts, unlike + // crypto.randomUUID) so cleanup can find the editor instance by id + const id = `twui-tinymce-${React.useId().replace(/[^a-zA-Z0-9_-]/g, "")}`; const updateContent = useCallback((editor: Editor) => { - if (changeHandler) { - window.clearTimeout(valueTimeout); + if (!changeHandlerRef.current) return; - valueTimeout = setTimeout(() => { - changeHandler( - editor.getContent(), - editorRef.current || undefined, - ); - }, EDITOR_VALUE_CHANGE_TIMEOUT); - } + window.clearTimeout(valueTimeoutRef.current); + + valueTimeoutRef.current = setTimeout(() => { + changeHandlerRef.current?.( + editor.getContent(), + editorRef.current || undefined, + ); + }, EDITOR_VALUE_CHANGE_TIMEOUT); }, []); React.useEffect(() => { @@ -105,7 +109,7 @@ export default function TinyMCEEditor({ base_url || getTinyMCEDefaultBaseUrl(); - tinyMCE.init({ + const initPromise = tinyMCE.init({ height: FINAL_HEIGHT, menubar: false, plugins: @@ -154,12 +158,12 @@ export default function TinyMCEEditor({ }); return function () { - if (!ready) return; - - const instance = editorComponentRef.current - ? tinyMCE?.get(editorComponentRef.current?.id) - : undefined; - instance?.remove(); + window.clearTimeout(valueTimeoutRef.current); + tinyMCE?.get(id)?.remove(); + // Init is async: also remove editors that finish after cleanup + initPromise + ?.then((editors) => editors.forEach((editor) => editor.remove())) + .catch(() => {}); }; }, [tinyMCE, themeReady, refresh, ...(refreshDependencyArray || [])]); diff --git a/src/editors/TinyMCE/useTinyMCE.tsx b/src/editors/TinyMCE/useTinyMCE.tsx index de66924..0bde823 100644 --- a/src/editors/TinyMCE/useTinyMCE.tsx +++ b/src/editors/TinyMCE/useTinyMCE.tsx @@ -2,8 +2,6 @@ import React from "react"; import type { TinyMCE } from "./tinymce"; import getTinyMCEDefaultBaseUrl from "./default-base-url"; -let interval: any; - type Params = { base_url?: string; }; @@ -29,7 +27,20 @@ export default function useTinyMCE(params?: Params) { params?.base_url || getTinyMCEDefaultBaseUrl(); - script.src = `${baseUrl}/tinymce.min.js`; + const src = `${baseUrl}/tinymce.min.js`; + + // Several editors may mount at once; share a single script tag + const existingScript = document.querySelector( + `script[src="${src}"]`, + ); + + if (existingScript) { + const onLoad = () => setScriptLoaded(true); + existingScript.addEventListener("load", onLoad); + return () => existingScript.removeEventListener("load", onLoad); + } + + script.src = src; script.async = true; script.onload = () => { diff --git a/src/elements/Breadcrumbs.tsx b/src/elements/Breadcrumbs.tsx index d08b6b0..1b6c8ac 100644 --- a/src/elements/Breadcrumbs.tsx +++ b/src/elements/Breadcrumbs.tsx @@ -1,228 +1,231 @@ -import React, { type ComponentProps, type ReactNode } from "react"; -import Link from "../layout/Link"; -import Divider from "../layout/Divider"; -import Row from "../layout/Row"; -import lowerToTitleCase from "../utils/lower-to-title-case"; -import { twMerge } from "tailwind-merge"; -import { ChevronLeft } from "lucide-react"; -import Button from "../layout/Button"; - -type LinkObject = { - title: string; - path: string; -}; - -type Props = { - excludeRegexMatch?: RegExp; - linkProps?: ComponentProps; - currentLinkProps?: ComponentProps; - dividerProps?: ComponentProps; - backButtonProps?: ComponentProps; - backButton?: boolean; - pageUrl?: string; - currentTitle?: string; - skipHome?: boolean; - divider?: ReactNode; -}; - -/** - * # TWUI Breadcrumbs - * @className `twui-breadcrumb-link` - * @className `twui-current-breadcrumb-wrapper` - * @className `twui-breadcrumbs-divider` - * @className `twui-breadcrumbs-back-button` - */ -export default function Breadcrumbs({ - excludeRegexMatch, - linkProps, - currentLinkProps, - dividerProps, - backButton, - backButtonProps, - pageUrl, - currentTitle, - skipHome, - divider, -}: Props) { - const [links, setLinks] = React.useState( - pageUrl - ? twuiBreadcrumbsGenerateLinksFromUrl({ url: pageUrl, skipHome }) - : null, - ); - - React.useEffect(() => { - if (links) return; - - let pathname = window.location.pathname; - - let validPathLinks = twuiBreadcrumbsGenerateLinksFromUrl({ - url: pathname, - excludeRegexMatch, - skipHome, - }); - - setLinks(validPathLinks); - - return function () { - setLinks(null); - }; - }, []); - - if (!links?.[1]) { - return ; - } - - return ( - - ); - //////////////////////////////////////// - //////////////////////////////////////// - //////////////////////////////////////// -} - -export function twuiBreadcrumbsGenerateLinksFromUrl({ - url, - excludeRegexMatch, - skipHome, -}: { - url: string; - excludeRegexMatch?: RegExp; - skipHome?: boolean; -}) { - let pathLinks = url.split("/"); - - let validPathLinks = []; - - if (!skipHome) { - validPathLinks.push({ - title: "Home", - path: url.match(/admin/) ? "/admin" : "/", - }); - } - - pathLinks.forEach((linkText, index, array) => { - if (!linkText?.match(/./)) { - return; - } - - if (excludeRegexMatch && excludeRegexMatch.test(linkText)) return; - - validPathLinks.push({ - title: lowerToTitleCase(linkText), - path: (() => { - let path = ""; - - for (let i = 0; i < array.length; i++) { - const lnText = array[i]; - if (i > index || !lnText?.match(/./)) continue; - - path += `/${lnText}`; - } - - return path; - })(), - }); - }); - - return validPathLinks; -} +import React, { type ComponentProps, type ReactNode } from "react"; +import Link from "../layout/Link"; +import Divider from "../layout/Divider"; +import Row from "../layout/Row"; +import lowerToTitleCase from "../utils/lower-to-title-case"; +import { twMerge } from "tailwind-merge"; +import { ChevronLeft } from "lucide-react"; +import Button from "../layout/Button"; + +type LinkObject = { + title: string; + path: string; +}; + +type Props = { + excludeRegexMatch?: RegExp; + linkProps?: ComponentProps; + currentLinkProps?: ComponentProps; + dividerProps?: ComponentProps; + backButtonProps?: ComponentProps; + backButton?: boolean; + pageUrl?: string; + currentTitle?: string; + skipHome?: boolean; + divider?: ReactNode; +}; + +/** + * # TWUI Breadcrumbs + * @className `twui-breadcrumb-link` + * @className `twui-current-breadcrumb-wrapper` + * @className `twui-breadcrumbs-divider` + * @className `twui-breadcrumbs-back-button` + */ +export default function Breadcrumbs({ + excludeRegexMatch, + linkProps, + currentLinkProps, + dividerProps, + backButton, + backButtonProps, + pageUrl, + currentTitle, + skipHome, + divider, +}: Props) { + const [links, setLinks] = React.useState( + pageUrl + ? twuiBreadcrumbsGenerateLinksFromUrl({ + url: pageUrl, + excludeRegexMatch, + skipHome, + }) + : null, + ); + + React.useEffect(() => { + if (links) return; + + let pathname = window.location.pathname; + + let validPathLinks = twuiBreadcrumbsGenerateLinksFromUrl({ + url: pathname, + excludeRegexMatch, + skipHome, + }); + + setLinks(validPathLinks); + + return function () { + setLinks(null); + }; + }, []); + + if (!links?.[1]) { + return ; + } + + return ( + + ); + //////////////////////////////////////// + //////////////////////////////////////// + //////////////////////////////////////// +} + +export function twuiBreadcrumbsGenerateLinksFromUrl({ + url, + excludeRegexMatch, + skipHome, +}: { + url: string; + excludeRegexMatch?: RegExp; + skipHome?: boolean; +}) { + let pathLinks = url.split("/"); + + let validPathLinks = []; + + if (!skipHome) { + validPathLinks.push({ + title: "Home", + path: url.match(/admin/) ? "/admin" : "/", + }); + } + + pathLinks.forEach((linkText, index, array) => { + if (!linkText?.match(/./)) { + return; + } + + if (excludeRegexMatch && excludeRegexMatch.test(linkText)) return; + + validPathLinks.push({ + title: lowerToTitleCase(linkText), + path: (() => { + let path = ""; + + for (let i = 0; i < array.length; i++) { + const lnText = array[i]; + if (i > index || !lnText?.match(/./)) continue; + + path += `/${lnText}`; + } + + return path; + })(), + }); + }); + + return validPathLinks; +} diff --git a/src/elements/Dropdown.tsx b/src/elements/Dropdown.tsx index 48a6b4b..73ea64d 100644 --- a/src/elements/Dropdown.tsx +++ b/src/elements/Dropdown.tsx @@ -106,6 +106,11 @@ export default function Dropdown({ React.useEffect(() => { setMounted(true); + + return () => { + window.clearTimeout(timeoutRef.current); + window.clearTimeout(openTimeoutRef.current); + }; }, []); /** @@ -146,6 +151,15 @@ export default function Dropdown({ case "bottom-right": style = { right: viewportWidth - rect.right, top: rect.bottom }; break; + case "top-left": + style = { left: rect.left, bottom: viewportHeight - rect.top }; + break; + case "top-right": + style = { + right: viewportWidth - rect.right, + bottom: viewportHeight - rect.top, + }; + break; case "top": style = { left: centerX, diff --git a/src/elements/HeaderNav.tsx b/src/elements/HeaderNav.tsx index b21fc65..9ee3538 100644 --- a/src/elements/HeaderNav.tsx +++ b/src/elements/HeaderNav.tsx @@ -6,6 +6,7 @@ import React, { import { twMerge } from "tailwind-merge"; import Row from "../layout/Row"; import HeaderNavLinkComponent from "./HeaderNavLinkComponent"; +import twuiLinkMatchesPath from "../utils/link-matches-path"; export type TWUI_HEADER_NAV_PROPS = DetailedHTMLProps< HTMLAttributes, @@ -84,17 +85,13 @@ export function twuiAddActiveLinksFn({ const linkAttr = linkEl.getAttribute("href"); - if (window.location.pathname === "/" && linkAttr == "/") { - linkEl.classList.add("active"); - } else if ( - isLinkStrict && - linkEl.getAttribute("href") === window.location.pathname - ) { - linkEl.classList.add("active"); - } else if ( + if ( linkAttr && - window.location.pathname.startsWith(linkAttr) && - !isLinkStrict + twuiLinkMatchesPath({ + href: linkAttr, + pathname: window.location.pathname, + strict: Boolean(isLinkStrict), + }) ) { linkEl.classList.add("active"); } diff --git a/src/elements/LinkList.tsx b/src/elements/LinkList.tsx index a272ccc..e8f092f 100644 --- a/src/elements/LinkList.tsx +++ b/src/elements/LinkList.tsx @@ -57,6 +57,9 @@ export default function LinkList({ ...props }: TWUI_LINK_LIST_PROPS) { const listRef = React.useRef(null); + const finalLinks = links + .flat() + .filter((ln): ln is TWUI_LINK_LIST_LINK_OBJECT => Boolean(ln)); React.useEffect(() => { twuiAddActiveLinksFn({ @@ -75,12 +78,7 @@ export default function LinkList({ props.className, )} > - {links - .flat() - .filter((ln) => Boolean(ln)) - .map((link, index) => { - if (!link) return null; - + {finalLinks.map((link, index) => { if (link.divider) return ( @@ -89,7 +87,7 @@ export default function LinkList({ ); const finalDivider = - index < links.length - 1 && + index < finalLinks.length - 1 && (dividerComponent ? ( dividerComponent ) : divider ? ( @@ -107,9 +105,12 @@ export default function LinkList({ className={twMerge( "p-2 cursor-pointer whitespace-nowrap", linkProps?.className, + buttonProps?.className, + link.buttonProps?.className, )} onClick={(e) => { link.onClick?.(e); + buttonProps?.onClick?.(e); link.buttonProps?.onClick?.(e); }} > @@ -138,6 +139,7 @@ export default function LinkList({ strict={link.strict} onClick={(e) => { link.onClick?.(e); + linkProps?.onClick?.(e); link.linkProps?.onClick?.(e); }} > diff --git a/src/elements/Modal.tsx b/src/elements/Modal.tsx index be90ad7..8ce1e0b 100644 --- a/src/elements/Modal.tsx +++ b/src/elements/Modal.tsx @@ -83,16 +83,20 @@ export default function Modal(props: TWUI_MODAL_PROPS) { } }, []); + const wasOpenRef = React.useRef(open); + React.useEffect(() => { existingSetOpen?.(open); - if (open == false) onClose?.(); + if (wasOpenRef.current && !open) onClose?.(); + wasOpenRef.current = open; }, [open]); React.useEffect(() => { setOpen(existingOpen || false); }, [existingOpen]); - const finalTargetRef = targetRef || React.useRef(null); + const internalTargetRef = React.useRef(null); + const finalTargetRef = targetRef || internalTargetRef; const finalPopoverReferenceRef = popoverReferenceRef || finalTargetRef; const popoverTargetActiveRef = React.useRef(false); diff --git a/src/elements/Pagination.tsx b/src/elements/Pagination.tsx index 82a182b..7c92446 100644 --- a/src/elements/Pagination.tsx +++ b/src/elements/Pagination.tsx @@ -26,10 +26,10 @@ export default function Pagination({ limit, ...props }: Props) { - if (!count || !page || !limit) - return ( - - ); + if (!page || !limit) + return ; + + if (!count) return null; const isLimit = limit * page >= count; @@ -44,10 +44,8 @@ export default function Pagination({ }} variant="outlined" size="small" - className={twMerge( - "p-1", - isLimit ? "opacity-40 pointer-events-none" : "", - )} + className="p-1" + disabled={isLimit} > @@ -63,17 +61,15 @@ export default function Pagination({ > {pages > 1 && ( diff --git a/src/elements/RemoteCodeBlock.tsx b/src/elements/RemoteCodeBlock.tsx index bed3298..1019a79 100644 --- a/src/elements/RemoteCodeBlock.tsx +++ b/src/elements/RemoteCodeBlock.tsx @@ -1,49 +1,57 @@ -import React from "react"; -import { serialize } from "next-mdx-remote/serialize"; -import remarkGfm from "remark-gfm"; -import rehypePrismPlus from "rehype-prism-plus"; -import { MDXRemote, type MDXRemoteSerializeResult } from "next-mdx-remote"; -import { useMDXComponents } from "../mdx/mdx-components"; - -export const TWUIPrismLanguages = ["shell", "javascript"] as const; - -type Props = { - content: string; - refresh?: number; -}; - -/** - * # CodeBlock - * - * @className `twui-remote-code-block-wrapper` - */ -export default function RemoteCodeBlock({ content, refresh }: Props) { - const [mdxSource, setMdxSource] = - React.useState>(); - - const { components } = useMDXComponents(); - - React.useEffect(() => { - serialize(content, { - mdxOptions: { - remarkPlugins: [remarkGfm], - rehypePlugins: [rehypePrismPlus], - }, - }).then((mdxSrc) => { - setMdxSource(mdxSrc); - }); - }, [refresh]); - - if (!mdxSource) { - return null; - } - - return ( - - ); -} +import React from "react"; +import { serialize } from "next-mdx-remote/serialize"; +import remarkGfm from "remark-gfm"; +import rehypePrismPlus from "rehype-prism-plus"; +import { MDXRemote, type MDXRemoteSerializeResult } from "next-mdx-remote"; +import { useMDXComponents } from "../mdx/mdx-components"; + +export const TWUIPrismLanguages = ["shell", "javascript"] as const; + +type Props = { + content: string; + refresh?: number; +}; + +/** + * # CodeBlock + * + * @className `twui-remote-code-block-wrapper` + */ +export default function RemoteCodeBlock({ content, refresh }: Props) { + const [mdxSource, setMdxSource] = + React.useState>(); + + const { components } = useMDXComponents(); + + React.useEffect(() => { + let cancelled = false; + + serialize(content, { + mdxOptions: { + remarkPlugins: [remarkGfm], + rehypePlugins: [rehypePrismPlus], + }, + }) + .then((mdxSrc) => { + if (!cancelled) setMdxSource(mdxSrc); + }) + .catch(() => {}); + + return () => { + cancelled = true; + }; + }, [content, refresh]); + + if (!mdxSource) { + return null; + } + + return ( + + ); +} diff --git a/src/elements/Search.tsx b/src/elements/Search.tsx index 70c2d99..b21052d 100644 --- a/src/elements/Search.tsx +++ b/src/elements/Search.tsx @@ -9,8 +9,6 @@ import React, { type ReactNode, } from "react"; -let timeout: any; - export type SearchProps = DetailedHTMLProps< React.HTMLAttributes, HTMLDivElement @@ -50,16 +48,21 @@ export default function Search({ props.defaultValue?.toString() || "", ); - React.useEffect(() => { - clearTimeout(timeout); + const timeoutRef = React.useRef(null); - timeout = setTimeout(() => { + React.useEffect(() => { + window.clearTimeout(timeoutRef.current); + + timeoutRef.current = setTimeout(() => { dispatch?.(input); changeHandler?.(input); }, delay); + + return () => window.clearTimeout(timeoutRef.current); }, [input]); - const inputRef = componentRef || React.useRef(null); + const internalInputRef = React.useRef(null); + const inputRef = componentRef || internalInputRef; // React.useEffect(() => { // if (props.autoFocus) { diff --git a/src/elements/StarRating.tsx b/src/elements/StarRating.tsx index 1c72b6e..7e6f84a 100644 --- a/src/elements/StarRating.tsx +++ b/src/elements/StarRating.tsx @@ -18,8 +18,6 @@ export type TWUI_STAR_RATING_PROPS = DetailedHTMLProps< > & StarProps; -let timeout: any; - /** * # Star Rating Component * @className_wrapper twui-star-rating @@ -41,12 +39,15 @@ export default function StarRating({ const starClicked = React.useRef(false); const sectionHovered = React.useRef(false); + const timeoutRef = React.useRef(null); React.useEffect(() => { - window.clearTimeout(timeout); - timeout = setTimeout(() => { - setValueExternal?.(finalValue); + window.clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + setValueExternal?.(selectedStarValue); }, 500); + + return () => window.clearTimeout(timeoutRef.current); }, [selectedStarValue]); return ( @@ -148,6 +149,7 @@ function StarComponent({ > ); diff --git a/src/elements/Table.tsx b/src/elements/Table.tsx index fb490cc..4cc4006 100644 --- a/src/elements/Table.tsx +++ b/src/elements/Table.tsx @@ -6,6 +6,18 @@ type Props = { data?: { [k: string]: any }[]; }; +function formatCellValue(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value === "object") { + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + return String(value); +} + export default function Table({ data }: Props) { if (!data || data.length === 0) { return ( @@ -62,9 +74,9 @@ export default function Table({ data }: Props) { "dark:text-foreground-dark max-w-[200px] overflow-hidden", "overflow-ellipsis", )} - title={row[header]} + title={formatCellValue(row[header])} > - {row[header]} + {formatCellValue(row[header])} ))} diff --git a/src/elements/Toast.tsx b/src/elements/Toast.tsx index 7b5fc87..858d379 100644 --- a/src/elements/Toast.tsx +++ b/src/elements/Toast.tsx @@ -19,9 +19,6 @@ export type TWUIToastProps = DetailedHTMLProps< color?: (typeof ToastStyles)[number]; }; -let interval: any; -let timeout: any; - /** * # Toast Component * @className twui-toast-root @@ -38,6 +35,7 @@ export default function Toast({ ...props }: TWUIToastProps) { const [ready, setReady] = React.useState(false); + const timeoutRef = React.useRef(null); const IDName = "twui-toast-root"; React.useEffect(() => { @@ -56,14 +54,13 @@ export default function Toast({ React.useEffect(() => { if (!ready || !open) return; - timeout = setTimeout(() => { + timeoutRef.current = setTimeout(() => { setOpen?.(false); closeDispatch?.(open); }, closeDelay); return function () { - setOpen?.(false); - closeDispatch?.(open); + window.clearTimeout(timeoutRef.current); }; }, [ready, open]); @@ -85,10 +82,11 @@ export default function Toast({ "twui-toast", )} onMouseEnter={() => { - window.clearTimeout(timeout); + window.clearTimeout(timeoutRef.current); }} onMouseLeave={(e) => { - timeout = setTimeout(() => { + window.clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { setOpen?.(false); closeDispatch?.(open); }, closeDelay); diff --git a/src/elements/ai/AIPromptActionSection.tsx b/src/elements/ai/AIPromptActionSection.tsx index 4a8144f..34c4922 100644 --- a/src/elements/ai/AIPromptActionSection.tsx +++ b/src/elements/ai/AIPromptActionSection.tsx @@ -90,6 +90,7 @@ export default function AIPromptActionSection({ loading={loading} className="p-2" onClick={() => { + if (!prompt.trim()) return; currentPromptRef.current = prompt; setTimeout(() => { setPrompt(""); diff --git a/src/elements/ai/AIPromptBlock.tsx b/src/elements/ai/AIPromptBlock.tsx index 0edef32..75ea0fc 100644 --- a/src/elements/ai/AIPromptBlock.tsx +++ b/src/elements/ai/AIPromptBlock.tsx @@ -28,9 +28,7 @@ export default function AIPromptBlock({ setMdRes, placeholder, }: Props) { - const [prompt, setPrompt] = React.useState(""); const currentPromptRef = React.useRef(""); - const promptInputRef = React.useRef(null); return ( @@ -64,6 +62,7 @@ export default function AIPromptBlock({ mdRes, setMdRes, placeholder, + currentPromptRef, }} /> diff --git a/src/elements/ai/AIPromptBlockInputSection.tsx b/src/elements/ai/AIPromptBlockInputSection.tsx index 807159c..cb79df6 100644 --- a/src/elements/ai/AIPromptBlockInputSection.tsx +++ b/src/elements/ai/AIPromptBlockInputSection.tsx @@ -13,6 +13,8 @@ type Props = { mdRes?: string; setMdRes: React.Dispatch>; placeholder?: string; + /** Receives the last submitted prompt */ + currentPromptRef?: React.MutableRefObject; }; export default function AIPromptBlockInputSection({ @@ -23,9 +25,12 @@ export default function AIPromptBlockInputSection({ mdRes = "", setMdRes, placeholder, + currentPromptRef: externalCurrentPromptRef, }: Props) { const [prompt, setPrompt] = React.useState(""); - const currentPromptRef = React.useRef(""); + const internalCurrentPromptRef = React.useRef(""); + const currentPromptRef = + externalCurrentPromptRef || internalCurrentPromptRef; const promptInputRef = React.useRef(null); return ( @@ -44,6 +49,7 @@ export default function AIPromptBlockInputSection({ onKeyDown={(e) => { if (e.key == "Enter" && !e.ctrlKey && !e.shiftKey) { e.preventDefault(); + if (!prompt.trim() || loading) return; currentPromptRef.current = prompt; setTimeout(() => { setPrompt(""); diff --git a/src/elements/ai/AIVoiceTranscription.tsx b/src/elements/ai/AIVoiceTranscription.tsx index 8298131..10d4760 100644 --- a/src/elements/ai/AIVoiceTranscription.tsx +++ b/src/elements/ai/AIVoiceTranscription.tsx @@ -53,9 +53,10 @@ export default function AIVoiceTranscription({ onClick={(e) => { e.preventDefault(); - const history_last_response = history?.find( - (h) => h.role == "assistant", - ); + const history_last_response = history + ?.slice() + .reverse() + .find((h) => h.role == "assistant"); voice_transcription_fn({ last_message: last_response || diff --git a/src/form/Checkbox.tsx b/src/form/Checkbox.tsx index a0e0da7..ba92dcd 100644 --- a/src/form/Checkbox.tsx +++ b/src/form/Checkbox.tsx @@ -71,9 +71,7 @@ export default function Checkbox({ defaultChecked || externalChecked || false, ); - const finalTitle = title - ? title - : `Checkbox-${Math.round(Math.random() * 100000)}`; + const finalTitle = title; React.useEffect(() => { if (typeof externalChecked == "undefined") return; diff --git a/src/form/FileUpload.tsx b/src/form/FileUpload.tsx index 411004c..04ba820 100644 --- a/src/form/FileUpload.tsx +++ b/src/form/FileUpload.tsx @@ -139,8 +139,8 @@ export default function FileUpload({ { if (multiple) { (async () => { @@ -154,11 +154,13 @@ export default function FileUpload({ const file = files[i]; const fileObj = await fileInputToBase64({ inputFile: file!, + allowedRegex, }); - filesArr.push(fileObj); + if (fileObj.fileBase64Full) filesArr.push(fileObj); } externalSetFiles?.(filesArr); + fileInputProps?.onChange?.(e); })(); } else { const inputFile = e.target.files?.[0]; @@ -249,8 +251,11 @@ export default function FileUpload({ ) : fileUrl ? ( {disablePreview ? ( @@ -295,6 +300,8 @@ export default function FileUpload({ onChangeHandler?.(undefined); changeHandler?.(undefined); setFileUrl(undefined); + externalSetFileURL?.(undefined); + onClear?.(); }} title="Cancel File Button" > @@ -303,6 +310,7 @@ export default function FileUpload({ ) : ( { setFile(res); externalSetFile?.(res); - onChangeHandler?.(res); - changeHandler?.(res); + onChangeHandler?.( + res, + inputRef, + fileInputUtils, + ); + changeHandler?.(res, inputRef, fileInputUtils); }, ); }} - {...placeHolderWrapper} >
{ setLoading?.(true); if (multiple) { (async () => { const files = e.target.files; - if (!files?.[0]) return; + if (!files?.[0]) { + setLoading?.(false); + return; + } let imgArr: ImageInputToBase64FunctionReturn[] = []; @@ -147,8 +150,11 @@ export default function ImageUpload({ {src || imageObject?.imageBase64Full ? ( {label && (