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 <[email protected]>
This commit is contained in:
2026-10-18 10:45:46 +01:00
co-authored by Claude Opus 5.5
parent 11da9b89e7
commit cdeeb8201e
56 changed files with 1128 additions and 722 deletions
+56 -91
View File
@@ -1,106 +1,71 @@
# Agent Instructions
Default to using Bun instead of Node.js.
## Stack
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Bun automatically loads .env, so don't use dotenv.
- **What this is:** `@moduletrace/twui`, a React component library for Tailwind CSS v4, published to the Moduletrace Gitea npm registry (`https://git.tben.me/api/packages/moduletrace/npm/`). It is consumed by other projects (e.g. `presets/bunext-sqlite`), so every change here is a change to a shared, published API.
- **Runtime/tooling:** Bun, not Node. Use `bun install`, `bun run <script>`, `bun test`, `bunx`. Never `npm`/`npx`/`yarn`/`pnpm`.
- **Build:** `tsc` emits `src/` → `dist/` (JS + `.d.ts`), then `scripts/post-compile.ts` runs. There is no Next.js, Vite, PostCSS config or `tailwind.config` in this repo - don't add them.
- **Styling:** Tailwind v4 via `src/base.css` (`@import "tailwindcss"`, `@source "./"`, `@theme inline` tokens). `tailwind-merge` (`twMerge`) for class composition.
- **Icons:** `lucide-react` (peer dependency).
## APIs
## Commands
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
| Command | What it does |
| ------------------- | ------------------------------------------------------------------- |
| `bun run typecheck` | `tsc --noEmit` - must be clean before finishing any change |
| `bun test src` | Unit tests (`src/**/*.test.ts`) |
| `bun run compile` | Clean build into `dist/` + post-compile step |
| `./publish.sh "msg"`| Typecheck → test → compile → `git commit/push` → `bun publish` |
## Testing
`publish.sh` pushes and publishes - only run it when explicitly asked. Bump `version` in `package.json` before publishing.
Use `bun test` to run tests.
## Layout
```ts#index.test.ts
import { test, expect } from "bun:test";
| Path | Contents |
| ----------------------------------------- | ------------------------------------------------------------------------ |
| `src/index.ts` | Root barrel (`import { Button } from "@moduletrace/twui"`) |
| `src/base.css` | Base stylesheet + default theme tokens; shipped as `@moduletrace/twui/base.css` |
| `src/layout/` | Primitives: `Stack`, `Row`, `Container`, `Section`, `Button`, `H1`-`H5`, `P`, `Span`, `Link`, `Img`, ... |
| `src/elements/` | Composite UI: `Card`, `Modal`, `Popover`, `Table`, `Tabs`, `Toast`, `Tag`, `LucideIcon`, ... |
| `src/elements/ai/` | AI prompt blocks (optional peers: `openai` types, `react-markdown`) |
| `src/form/` | `Form`, `Input` (folder index), `Select`, `SearchSelect`, `Textarea`, `Checkbox`, uploads |
| `src/hooks/` | `useStatus`, `useLocalStorage`, `useWebSocket`, ... |
| `src/editors/` | `AceEditor` (optional `ace-builds`), `TinyMCE` (folder index, loaded from a URL at runtime) |
| `src/mdx/`, `src/next-js/` | MDX helpers (optional `next-mdx-remote`, `remark-gfm`, `rehype-prism-plus`) |
| `src/composites/docs/` | Docs page layout pieces |
| `src/utils/` | Pure helpers (`slugify`, `numberfy`, `ejson`, `fetchApi`, ...) with colocated tests |
| `src/(partials)/`, `src/(functions)/`, `src/(utils)/` | Internal helpers, not meant for direct import |
| `src/types.ts` | Shared types/constants |
| `scripts/post-compile.ts` | Copies `base.css`, adds `.js` to relative imports, prepends `"use client"` |
test("hello world", () => {
expect(1).toBe(1);
});
```
## Public API Rules (MANDATORY)
## Frontend
- **Deep import paths are public API.** Consumers import `@moduletrace/twui/<folder>/<File>` (the `"./*"` export maps to `dist/*.js`). Renaming or moving a file, or renaming an exported name, is a breaking change - avoid it, or call it out and bump the version accordingly.
- **Folder-index modules need explicit `exports` entries.** `"./*"` can't resolve `form/Input` → `form/Input/index.js`. If you add a component as `Foo/index.tsx`, add a matching entry in `package.json` `exports` (see `./form/Input`, `./editors/TinyMCE`, `./composites/docs`).
- **Keep the barrel in sync.** A new component/hook/util must be added to `src/index.ts` as `export { default as Name } from "./path"` (plus `export * from "./path"` if it exports types/constants). **Exception:** anything that imports an optional peer (editors, mdx, next-js, `elements/ai`, `RemoteCodeBlock`) stays deep-import only - the barrel must never pull in optional peers.
- **Exported names must be unique across the barrel.** Name props types after their component (`TWUIButtonProps`, `TWUI_TABS_PROPS`), never copy another component's type name. `bun run typecheck` reports clashes as TS2308.
- **`twui-*` class names are API too.** Every component appends a stable `twui-<component>[-variant]` class (e.g. `twui-button-primary`, `twui-card`) that consumers target in their own CSS. Keep existing ones; add one for new components/variants and list it in the component's JSDoc as `@className twui-...` (see `src/elements/Card.tsx`).
- **Theme tokens are API.** Components use semantic token utilities (`bg-primary`, `hover:bg-primary-hover`, `dark:bg-primary-dark`, `text-foreground-light`, ...) defined in `src/base.css` `@theme inline`. Don't hardcode hex colors in components; if a new token is needed, add it to `base.css` with a sensible default.
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
## Dependencies
Server:
- `dependencies`: only `tailwind-merge` and `lodash`. Keep this list minimal.
- `peerDependencies`: `react`, `react-dom`, `tailwindcss` (^4), `lucide-react`.
- Feature-specific packages are **optional peers** (`peerDependenciesMeta.optional`) and also listed in `devDependencies` so the repo typechecks. Follow that pattern for any new third-party package used by a single feature, and update the optional-peers table in `README.md`.
- Don't let optional peers leak into `.d.ts` files reachable from the barrel (e.g. `src/types.ts` must not import from `ace-builds`).
```ts#index.ts
import index from "./index.html"
## Conventions
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```
- **Relative imports only** inside `src/` - there is no `@/` path alias. Don't add file extensions in source; `post-compile.ts` adds `.js` to the emitted output.
- **One component per file, default export**, named in PascalCase matching the file (hooks/utils use their function name). Prop types extend the underlying element's props (`DetailedHTMLProps<...>` or `ComponentProps<typeof Stack>`) and spread the rest onto the root element.
- **Merge classes with `twMerge`**, with the caller's `className` last so it wins.
- **Constant unions** are exported as `as const` arrays plus a derived type (`TWUIButtonColors` / `TWUIButtonColorType`).
- **Don't write `"use client"` in source** - `post-compile.ts` adds it to every emitted module except the barrel, `types.js`, `utils/` and `(utils)/`. Keep `utils/` free of React/DOM-only code so it stays server-safe.
- **Env access:** never read `process.env` directly in components (it throws in browser bundles without `process`). Wrap each lookup in `try/catch` and keep the full literal `process.env.X` expression so bundlers can inline it - see `src/editors/TinyMCE/default-base-url.ts`.
- **SSR safety:** guard `window`/`document`/`localStorage` access inside effects or `typeof window !== "undefined"` checks, never at module top level.
- **Tests:** pure helpers in `src/utils/` get a colocated `*.test.ts` using `bun:test`. Test files are excluded from the build.
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
## Verifying a Change
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```
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 <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
```
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.
@@ -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%)";
}
+23 -1
View File
@@ -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({
}}
></div>
<Paper
{..._.omit(props, ["targetWrapperProps"])}
{..._.omit(props, [
"target",
"targetRef",
"targetWrapperProps",
"popoverReferenceRef",
"isPopover",
"position",
"trigger",
"debounce",
"onClose",
"hoverOpen",
])}
className={twMerge(
"z-10 max-w-modal bg-background-light dark:bg-background-dark",
"w-full relative max-h-[95vh] overflow-y-auto",
+8 -3
View File
@@ -30,8 +30,6 @@ export default function PopoverComponent({
isPopover,
...props
}: Props) {
if (!open) return null;
const [style, setStyle] = React.useState({});
React.useEffect(() => {
@@ -68,7 +66,14 @@ export default function PopoverComponent({
return ReactDOM.createPortal(
<Paper
{...props}
{..._.omit(props, [
"target",
"targetRef",
"targetWrapperProps",
"onClose",
"hoverOpen",
"no_cancel_button",
])}
className={twMerge(
"max-w-[300px] z-[250]",
"twui-popover-content",
+14 -6
View File
@@ -10,6 +10,7 @@ import Row from "../../layout/Row";
import Divider from "../../layout/Divider";
import { ChevronDown, Circle } from "lucide-react";
import Button from "../../layout/Button";
import twuiLinkMatchesPath from "../../utils/link-matches-path";
type Props = DetailedHTMLProps<
AnchorHTMLAttributes<HTMLAnchorElement>,
@@ -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 (
<Stack
className={twMerge("gap-2 w-full", wrapperProps?.className)}
{...wrapperProps}
className={twMerge("gap-2 w-full", wrapperProps?.className)}
>
<Row className="flex-nowrap grow justify-between w-full">
{child && <Circle size={6} />}
@@ -105,11 +113,11 @@ export default function TWUIDocsLink({
{docLink.children && expand && (
<Row className="items-stretch gap-4 grow w-full flex-nowrap">
<Stack
{...childWrapperProps}
className={twMerge(
"gap-2 w-full pl-3",
childWrapperProps?.className,
)}
{...childWrapperProps}
>
{docLink.children.map((link, index) => (
<TWUIDocsLink
@@ -116,6 +116,8 @@ export default function TWUIDocsRightAside({
headerHrefs.forEach((headerHref) => {
observer.observe(headerHref);
});
return () => observer.disconnect();
}, [links]);
React.useEffect(() => {
+4 -4
View File
@@ -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<HTMLDivElement>(undefined);
const editorRefInstance = React.useRef<AceAjax.Editor>(undefined);
const changeTimeoutRef = React.useRef<any>(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]);
+24 -20
View File
@@ -52,8 +52,12 @@ export default function TinyMCEEditor<KeyType extends string>({
const { tinyMCE } = useTinyMCE({ base_url });
const editorComponentRef = React.useRef<HTMLDivElement>(null);
const internalEditorRef = React.useRef<Editor | null>(null);
const editorRef: RefObject<Editor | null> =
passedEditorRef || React.useRef(null);
passedEditorRef || internalEditorRef;
const changeHandlerRef = React.useRef(changeHandler);
changeHandlerRef.current = changeHandler;
const valueTimeoutRef = React.useRef<any>(null);
const EDITOR_VALUE_CHANGE_TIMEOUT = 500;
@@ -79,21 +83,21 @@ export default function TinyMCEEditor<KeyType extends string>({
}, 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<KeyType extends string>({
base_url ||
getTinyMCEDefaultBaseUrl();
tinyMCE.init({
const initPromise = tinyMCE.init({
height: FINAL_HEIGHT,
menubar: false,
plugins:
@@ -154,12 +158,12 @@ export default function TinyMCEEditor<KeyType extends string>({
});
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 || [])]);
+14 -3
View File
@@ -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<HTMLScriptElement>(
`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 = () => {
+6 -3
View File
@@ -46,7 +46,11 @@ export default function Breadcrumbs({
}: Props) {
const [links, setLinks] = React.useState<LinkObject[] | null>(
pageUrl
? twuiBreadcrumbsGenerateLinksFromUrl({ url: pageUrl, skipHome })
? twuiBreadcrumbsGenerateLinksFromUrl({
url: pageUrl,
excludeRegexMatch,
skipHome,
})
: null,
);
@@ -155,8 +159,7 @@ export default function Breadcrumbs({
isTarget && currentLinkProps?.className,
)}
>
{currentLinkProps?.title ||
linkObject.title}
{linkObject.title}
</Link>
{divider || (
<Divider
+14
View File
@@ -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,
+7 -10
View File
@@ -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<HTMLElement>,
@@ -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");
}
+9 -7
View File
@@ -57,6 +57,9 @@ export default function LinkList({
...props
}: TWUI_LINK_LIST_PROPS) {
const listRef = React.useRef<HTMLDivElement>(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 (
<React.Fragment key={index}>
@@ -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);
}}
>
+6 -2
View File
@@ -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<HTMLDivElement>(null);
const internalTargetRef = React.useRef<HTMLDivElement>(null);
const finalTargetRef = targetRef || internalTargetRef;
const finalPopoverReferenceRef = popoverReferenceRef || finalTargetRef;
const popoverTargetActiveRef = React.useRef(false);
+9 -13
View File
@@ -26,10 +26,10 @@ export default function Pagination({
limit,
...props
}: Props) {
if (!count || !page || !limit)
return (
<EmptyContent title={`count, page, and limit are all required`} />
);
if (!page || !limit)
return <EmptyContent title={`page and limit are both required`} />;
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}
>
<ChevronRight size={20} />
</Button>
@@ -63,17 +61,15 @@ export default function Pagination({
>
{pages > 1 && (
<Button
title="Next Page Button"
title="Previous Page Button"
onClick={() => {
window.scrollTo({ top: 0, behavior: "smooth" });
setPage?.((prev) => prev - 1);
}}
variant="outlined"
size="small"
className={twMerge(
"p-1",
page == 1 ? "opacity-40 pointer-events-none" : "",
)}
className="p-1"
disabled={page == 1}
>
<ChevronLeft size={20} />
</Button>
+12 -4
View File
@@ -24,15 +24,23 @@ export default function RemoteCodeBlock({ content, refresh }: Props) {
const { components } = useMDXComponents();
React.useEffect(() => {
let cancelled = false;
serialize(content, {
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypePrismPlus],
},
}).then((mdxSrc) => {
setMdxSource(mdxSrc);
});
}, [refresh]);
})
.then((mdxSrc) => {
if (!cancelled) setMdxSource(mdxSrc);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [content, refresh]);
if (!mdxSource) {
return null;
+9 -6
View File
@@ -9,8 +9,6 @@ import React, {
type ReactNode,
} from "react";
let timeout: any;
export type SearchProps<KeyType extends string> = DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
@@ -50,16 +48,21 @@ export default function Search<KeyType extends string>({
props.defaultValue?.toString() || "",
);
React.useEffect(() => {
clearTimeout(timeout);
const timeoutRef = React.useRef<any>(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<HTMLInputElement>(null);
const internalInputRef = React.useRef<HTMLInputElement>(null);
const inputRef = componentRef || internalInputRef;
// React.useEffect(() => {
// if (props.autoFocus) {
+7 -6
View File
@@ -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<any>(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({
>
<Star
size={size}
{...starProps}
className={twMerge(
"text-slate-300 dark:text-white/20",
isActive &&
@@ -156,7 +158,6 @@ function StarComponent({
// "hover:text-orange-500 hover:dark:text-orange-400 hover:fill-orange-500 hover:dark:fill-orange-400",
starProps?.className,
)}
{...starProps}
/>
</div>
);
+14 -2
View File
@@ -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])}
</td>
))}
</tr>
+6 -8
View File
@@ -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<any>(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);
@@ -90,6 +90,7 @@ export default function AIPromptActionSection({
loading={loading}
className="p-2"
onClick={() => {
if (!prompt.trim()) return;
currentPromptRef.current = prompt;
setTimeout(() => {
setPrompt("");
+1 -2
View File
@@ -28,9 +28,7 @@ export default function AIPromptBlock({
setMdRes,
placeholder,
}: Props) {
const [prompt, setPrompt] = React.useState("");
const currentPromptRef = React.useRef("");
const promptInputRef = React.useRef<HTMLTextAreaElement>(null);
return (
<Paper className="">
@@ -64,6 +62,7 @@ export default function AIPromptBlock({
mdRes,
setMdRes,
placeholder,
currentPromptRef,
}}
/>
</Stack>
@@ -13,6 +13,8 @@ type Props = {
mdRes?: string;
setMdRes: React.Dispatch<React.SetStateAction<string>>;
placeholder?: string;
/** Receives the last submitted prompt */
currentPromptRef?: React.MutableRefObject<string>;
};
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<HTMLTextAreaElement>(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("");
+4 -3
View File
@@ -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 ||
+1 -3
View File
@@ -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;
+17 -6
View File
@@ -139,8 +139,8 @@ export default function FileUpload({
<input
type="file"
multiple={multiple}
className={twMerge("hidden", fileInputProps?.className)}
{...fileInputProps}
className={twMerge("hidden", fileInputProps?.className)}
onChange={(e) => {
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({
</Card>
) : fileUrl ? (
<Card
className="w-full relative h-full items-center justify-center overflow-hidden"
{...previewImageWrapperProps}
className={twMerge(
"w-full relative h-full items-center justify-center overflow-hidden",
previewImageWrapperProps?.className,
)}
>
<Stack className="w-full">
{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({
</Card>
) : (
<Card
{...placeHolderWrapper}
className={twMerge(
"w-full h-full cursor-pointer hover:bg-slate-100/50 dark:hover:bg-white/5",
"border-dashed border-2",
@@ -343,12 +351,15 @@ export default function FileUpload({
(res) => {
setFile(res);
externalSetFile?.(res);
onChangeHandler?.(res);
changeHandler?.(res);
onChangeHandler?.(
res,
inputRef,
fileInputUtils,
);
changeHandler?.(res, inputRef, fileInputUtils);
},
);
}}
{...placeHolderWrapper}
>
<Center
className={twMerge(
+12 -6
View File
@@ -104,17 +104,20 @@ export default function ImageUpload({
>
<input
type="file"
className={twMerge("hidden", fileInputProps?.className)}
multiple={multiple}
accept="image/*"
{...fileInputProps}
className={twMerge("hidden", fileInputProps?.className)}
onChange={(e) => {
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 ? (
<Card
className="w-full relative h-full items-center justify-center"
{...previewImageWrapperProps}
className={twMerge(
"w-full relative h-full items-center justify-center",
previewImageWrapperProps?.className,
)}
>
{label && (
<label
@@ -183,7 +189,7 @@ export default function ImageUpload({
setImageObject?.(undefined);
externalSetImage?.(undefined);
if (inputRef.current) {
inputRef.current.value == "";
inputRef.current.value = "";
}
}}
title="Cancel Image Upload Button"
@@ -193,6 +199,7 @@ export default function ImageUpload({
</Card>
) : (
<Card
{...placeHolderWrapper}
className={twMerge(
"w-full h-full cursor-pointer hover:bg-slate-100 dark:hover:bg-white/20",
placeHolderWrapper?.className,
@@ -206,7 +213,6 @@ export default function ImageUpload({
inputRef.current?.click();
placeHolderWrapper?.onClick?.(e);
}}
{...placeHolderWrapper}
>
<Center>
<Stack className="items-center gap-2">
@@ -229,7 +235,7 @@ export default function ImageUpload({
}}
/>
<Button
title="Restore Image Button"
title="Set Image URL Button"
size="smaller"
variant="outlined"
color="gray"
+7
View File
@@ -59,6 +59,7 @@ export default function NumberInputButtons({
function incrementDownPress() {
window.clearTimeout(pressTimeout);
window.clearInterval(pressInterval);
setButtonDown(true);
pressTimeout = setTimeout(() => {
@@ -75,6 +76,8 @@ export default function NumberInputButtons({
}
function decrementDownPress() {
window.clearTimeout(pressTimeout);
window.clearInterval(pressInterval);
setButtonDown(true);
pressTimeout = setTimeout(() => {
@@ -143,7 +146,9 @@ export default function NumberInputButtons({
onMouseDown={decrementDownPress}
onTouchStart={decrementDownPress}
onMouseUp={decrementDownCancel}
onMouseLeave={decrementDownCancel}
onTouchEnd={decrementDownCancel}
onTouchCancel={decrementDownCancel}
>
<Minus size={20} />
</Row>
@@ -156,7 +161,9 @@ export default function NumberInputButtons({
onMouseDown={incrementDownPress}
onTouchStart={incrementDownPress}
onMouseUp={incrementDownCancel}
onMouseLeave={incrementDownCancel}
onTouchEnd={incrementDownCancel}
onTouchCancel={incrementDownCancel}
>
<Plus size={20} />
</Row>
+37 -28
View File
@@ -153,8 +153,10 @@ export default function Input<KeyType extends string>(
},
);
const inputRef = componentRef || React.useRef<HTMLInputElement>(null);
const textAreaRef = componentRef || React.useRef<HTMLTextAreaElement>(null);
const internalInputRef = React.useRef<HTMLInputElement>(null);
const internalTextAreaRef = React.useRef<HTMLTextAreaElement>(null);
const inputRef = componentRef || internalInputRef;
const textAreaRef = componentRef || internalTextAreaRef;
const buttonDownRef = React.useRef(false);
const [value, setValue] = React.useState(
@@ -344,7 +346,11 @@ export default function Input<KeyType extends string>(
return (
<Stack
title={`${finalLabel}${props.required ? " (Required)" : ""}`}
title={
finalLabel
? `${finalLabel}${props.required ? " (Required)" : ""}`
: undefined
}
{...wrapperWrapperProps}
className={twMerge(
"w-full gap-1.5 relative z-0 hover:z-100",
@@ -417,9 +423,9 @@ export default function Input<KeyType extends string>(
title="Copy value"
className="cursor-pointer opacity-50 hover:opacity-100"
onClick={() => {
const text =
inputRef.current.value ||
textAreaRef.current.value;
const text = (
istextarea ? textAreaRef : inputRef
).current?.value;
if (text) {
navigator.clipboard
@@ -446,14 +452,18 @@ export default function Input<KeyType extends string>(
onClick={(e) => {
e.preventDefault();
if (inputRef.current) {
inputRef.current.value = "";
}
if (textAreaRef.current) {
textAreaRef.current.value = "";
const element = istextarea
? textAreaRef.current
: inputRef.current;
// Goes through React's onChange so controlled
// inputs and `onChange` listeners see the clear
if (element) {
setNativeInputValue(element, "");
} else {
setValue("");
}
setValue("");
clearInputProps?.onClick?.(e);
}}
>
@@ -465,7 +475,7 @@ export default function Input<KeyType extends string>(
<div
title={
inputType == "password"
? "View Psasword"
? "View Password"
: "Hide Password"
}
className={twMerge("p-1 -my-2 -mx-1")}
@@ -545,25 +555,24 @@ export default function Input<KeyType extends string>(
const PICKER_INPUT_TYPES = ["date", "datetime-local", "time", "month", "week"];
const updateInput = ({
newValue,
inputRef,
}: {
newValue: string;
inputRef: RefObject<HTMLInputElement & HTMLTextAreaElement>;
}) => {
const input = inputRef.current;
if (!input) return;
function setNativeInputValue(
element: HTMLInputElement | HTMLTextAreaElement,
newValue: string,
) {
const prototype =
element instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype;
// Get the native setter (React overrides the default one)
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
const nativeValueSetter = Object.getOwnPropertyDescriptor(
prototype,
"value",
)?.set;
// Call it directly on the input
nativeInputValueSetter?.call(input, newValue);
// Call it directly on the element
nativeValueSetter?.call(element, newValue);
// Dispatch the event - React listens for 'input', not 'change'
input.dispatchEvent(new Event("input", { bubbles: true }));
};
element.dispatchEvent(new Event("input", { bubbles: true }));
}
+16 -15
View File
@@ -153,14 +153,12 @@ export default function SearchSelect<
const commit = React.useCallback(
(option: TWUISelectOptionObject<KeyType, T>) => {
if (multiple) {
setSelected((prev) => {
const exists = prev.some((s) => s.value === option.value);
const next = exists
? prev.filter((s) => s.value !== option.value)
: [...prev, option];
notify(next);
return next;
});
const exists = selected.some((s) => s.value === option.value);
const next = exists
? selected.filter((s) => s.value !== option.value)
: [...selected, option];
setSelected(next);
notify(next);
setQuery("");
setOpen(true);
setActiveIndex(0);
@@ -174,20 +172,18 @@ export default function SearchSelect<
notify(next);
}
},
[multiple, notify],
[multiple, notify, selected],
);
const removeOne = React.useCallback(
(value: KeyType) => {
setSelected((prev) => {
const next = prev.filter((s) => s.value !== value);
notify(next);
return next;
});
const next = selected.filter((s) => s.value !== value);
setSelected(next);
notify(next);
if (!multiple) setQuery("");
inputRef.current?.focus();
},
[multiple, notify],
[multiple, notify, selected],
);
const clear = React.useCallback(() => {
@@ -204,6 +200,11 @@ export default function SearchSelect<
React.useEffect(() => {
if (initRef.current || !initialSelected.length) return;
initRef.current = true;
// Options may arrive after mount, so sync the visible state too
setSelected(initialSelected);
if (!multiple && initialSelected[0]) {
setQuery(getOptionLabel(initialSelected[0]));
}
notify(initialSelected);
}, [initialSelected, notify]);
+31 -10
View File
@@ -102,15 +102,35 @@ export default function Select<
isValid: true,
});
const selectRef = componentRef || React.useRef<HTMLSelectElement>(null);
const internalSelectRef = React.useRef<HTMLSelectElement>(null);
const selectRef = componentRef || internalSelectRef;
const generatedId = React.useId();
const flatOptions = options.flat();
const defaultOption = flatOptions.find((opt) => opt.default);
const initialOption = defaultOption || flatOptions[0];
const [value, setValue] = React.useState<TWUISelectValueObject<KeyType, T>>(
{
value: options[0]?.value!,
data: options[0]?.data,
value: initialOption?.value!,
data: initialOption?.data,
},
);
/**
* Selected value when an option is marked `default`. Kept in state so
* the user can still change the selection, and re-synced whenever the
* `default` option changes (e.g. options derived from parent state).
*/
const [selectedValue, setSelectedValue] = React.useState<
string | undefined
>(defaultOption?.value);
React.useEffect(() => {
if (defaultOption?.value === undefined) return;
setSelectedValue(defaultOption.value);
}, [defaultOption?.value]);
React.useEffect(() => {
setTimeout(() => {
requestAnimationFrame(() => {
@@ -135,7 +155,7 @@ export default function Select<
? twuiSlugify(props.name)
: props.title
? twuiSlugify(props.title)
: `select-${Math.round(Math.random() * 1000000)}`;
: `select-${generatedId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
return (
<Stack
@@ -181,15 +201,16 @@ export default function Select<
props.className,
)}
ref={selectRef}
value={
options.flat().find((opt) => opt.default)?.value ||
undefined
}
value={props.value ?? selectedValue}
onChange={(e) => {
const targetValue = options.find(
const targetValue = flatOptions.find(
(opt) => opt.value == e.target.value,
);
if (selectedValue !== undefined) {
setSelectedValue(e.target.value);
}
if (targetValue) {
setValue(targetValue);
}
@@ -206,7 +227,7 @@ export default function Select<
});
}}
>
{options.flat().map((option, index) => {
{flatOptions.map((option, index) => {
const optionTitle =
option.title || twuiSlugToNormalText(option.value);
+1 -1
View File
@@ -31,7 +31,7 @@ export default function useCustomEventDispatch<
});
window.dispatchEvent(event);
}, []);
}, [name]);
return { dispatchCustomEvent };
}
+1 -1
View File
@@ -33,7 +33,7 @@ export default function useCustomEventListener<
return function () {
window.removeEventListener(name, dataEventListenerCallback);
};
}, []);
}, [name]);
return { data, message };
}
+9 -6
View File
@@ -53,12 +53,10 @@ export default function useIntersectionObserver({
: null;
if (!element && !className && refresh < 5) {
requestAnimationFrame(() => {
setTimeout(() => {
setRefresh(refresh + 1);
}, 2000);
});
return;
const retryTimeout = setTimeout(() => {
setRefresh(refresh + 1);
}, 2000);
return () => window.clearTimeout(retryTimeout);
}
const observer = new IntersectionObserver(observerCallback, {
@@ -73,6 +71,11 @@ export default function useIntersectionObserver({
} else if (element) {
observer.observe(element);
}
return () => {
observer.disconnect();
window.clearTimeout(timeoutRef.current);
};
}, [refresh]);
return { isIntersecting };
+5 -7
View File
@@ -4,24 +4,22 @@ type Params = {
timeout?: number;
};
let timeout: any;
export default function useReady(params?: Params) {
const [ready, setReady] = React.useState(false);
const timeoutRef = React.useRef<any>(null);
const finalTimeout = params?.timeout || 300;
React.useEffect(() => {
requestAnimationFrame(() => {
window.clearTimeout(timeout);
timeout = setTimeout(() => {
const frame = requestAnimationFrame(() => {
timeoutRef.current = setTimeout(() => {
setReady(true);
}, finalTimeout);
});
return function () {
window.clearTimeout(timeout);
cancelAnimationFrame(frame);
window.clearTimeout(timeoutRef.current);
};
}, []);
+29 -10
View File
@@ -78,6 +78,19 @@ export default function useWebSocket<
[]
);
/**
* # Close a socket without triggering the reconnect logic
*/
const closeSocket = React.useCallback((ws?: WebSocket) => {
if (!ws) return;
ws.onclose = null;
ws.onerror = null;
ws.onmessage = null;
ws.onopen = null;
window.clearInterval(keepAliveInterval.current);
ws.close();
}, []);
/**
* # Connect to Websocket
*/
@@ -91,6 +104,7 @@ export default function useWebSocket<
if (!wsURL) return;
const ws = new WebSocket(wsURL);
socketRef.current = ws;
ws.onerror = () => {
console.log(`Websocket ERROR:`);
@@ -110,12 +124,13 @@ export default function useWebSocket<
}, keepAliveDurationRef.current);
tries.current = 0;
socketRef.current = ws;
setSocket(ws);
console.log(`Websocket connected to ${wsURL}`);
};
ws.onclose = (ev) => {
if (socketRef.current !== ws) return;
console.log("Websocket closed!", {
code: ev.code,
reason: ev.reason,
@@ -150,7 +165,8 @@ export default function useWebSocket<
window.clearTimeout(reconnectTimeout.current);
window.clearInterval(keepAliveInterval.current);
window.clearInterval(refreshInterval.current);
socketRef.current?.close();
closeSocket(socketRef.current);
socketRef.current = undefined;
};
}, []);
@@ -163,7 +179,9 @@ export default function useWebSocket<
refreshInterval.current = window.setInterval(() => {
console.log("Refreshing WebSocket connection...");
window.clearTimeout(reconnectTimeout.current);
socketRef.current?.close();
closeSocket(socketRef.current);
socketRef.current = undefined;
setSocket(undefined);
tries.current = 0;
connect();
}, refreshConnection);
@@ -187,16 +205,17 @@ export default function useWebSocket<
* Received Message Queue Handler
*/
const handleReceivedMessageQueue = React.useCallback(() => {
try {
while (messageQueueRef.current.length) {
const msg = messageQueueRef.current.shift();
if (!msg) continue;
if (!msg) return;
const jsonData = JSON.parse(msg);
dispatchCustomEvent("wsMessageEvent", msg);
dispatchCustomEvent("wsDataEvent", jsonData);
} catch (error) {
console.log("Unable to parse string. Returning string.");
try {
dispatchCustomEvent("wsDataEvent", JSON.parse(msg));
} catch (error) {
// Non-JSON messages are only dispatched as `wsMessageEvent`
}
}
}, []);
+11 -1
View File
@@ -3,6 +3,7 @@ import { ArrowUpRight } from "lucide-react";
import type { TWUI_LINK_LIST_LINK_OBJECT } from "../elements/LinkList";
import Link from "./Link";
import Row from "./Row";
import { twMerge } from "tailwind-merge";
type Props = ComponentProps<typeof Link> & {
link: TWUI_LINK_LIST_LINK_OBJECT;
@@ -15,7 +16,16 @@ type Props = ComponentProps<typeof Link> & {
*/
export default function ArrowedLink({ link, icon, ...props }: Props) {
return (
<Link href={link.url} {...props} {...link.linkProps}>
<Link
href={link.url}
{...props}
{...link.linkProps}
className={twMerge(
"twui-arrowed-link",
props.className,
link.linkProps?.className,
)}
>
<Row>
<span>{link.title}</span>
{icon || <ArrowUpRight size={17} />}
+2
View File
@@ -277,6 +277,8 @@ export default function Button({
const buttonComponent = (
<button
{...props}
disabled={props.disabled || loading}
aria-busy={loading || undefined}
className={twMerge(
"bg-primary text-white font-medium px-4 py-2 rounded-default",
"flex items-center justify-center relative transition-all cursor-pointer",
+3 -1
View File
@@ -2,6 +2,7 @@ import type { ComponentProps, ReactNode } from "react";
import type { TWUI_LINK_LIST_LINK_OBJECT } from "../elements/LinkList";
import Link from "./Link";
import Row from "./Row";
import { twMerge } from "tailwind-merge";
type Props = ComponentProps<typeof Link> & {
link: TWUI_LINK_LIST_LINK_OBJECT;
@@ -11,7 +12,7 @@ type Props = ComponentProps<typeof Link> & {
/**
* # Link With an Icon
* @className twui-arrowed-link
* @className twui-icon-link
*/
export default function IconLink({
link,
@@ -23,6 +24,7 @@ export default function IconLink({
<Link
href={link.url}
{...props}
className={twMerge("twui-icon-link", props.className)}
onClick={(e) => {
link.onClick?.(e);
props.onClick?.(e);
+20 -12
View File
@@ -85,6 +85,23 @@ export default function Img({
},
};
useEffect(() => {
setImageError(false);
}, [props.src, props.srcLight, props.srcDark]);
/** Try `fallbackImageSrc` first, then switch to the error state */
const handleError = (e: React.SyntheticEvent<HTMLImageElement, Event>) => {
const fallbackSrc = props.fallbackImageSrc;
if (fallbackSrc && e.currentTarget.getAttribute("src") !== fallbackSrc) {
e.currentTarget.src = fallbackSrc;
} else {
setImageError(true);
}
props.onError?.(e);
};
useEffect(() => {
if (checkImageFn) {
(async () => {
@@ -122,10 +139,7 @@ export default function Img({
interpolatedProps.className,
)}
src={props.srcDark}
onError={(e) => {
setImageError(true);
props.onError?.(e);
}}
onError={handleError}
/>
<img
loading="lazy"
@@ -135,10 +149,7 @@ export default function Img({
interpolatedProps.className,
)}
src={props.srcLight}
onError={(e) => {
setImageError(true);
props.onError?.(e);
}}
onError={handleError}
/>
</React.Fragment>
);
@@ -149,10 +160,7 @@ export default function Img({
loading="lazy"
decoding="async"
{...interpolatedProps}
onError={(e) => {
setImageError(true);
props.onError?.(e);
}}
onError={handleError}
/>
);
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { twMerge } from "tailwind-merge";
/**
* # Main Wrapper
* @className twui-h1
* @className twui-main
*/
export default function Main({
...props
+12 -1
View File
@@ -25,10 +25,21 @@ export default function Span({
size == "large" && "text-lg",
size == "larger" && "text-xl",
variant == "faded" && "opacity-50",
truncate ? `` : ``,
"twui-span",
props.className,
)}
style={
truncate
? {
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: truncate.lines || 1,
overflow: "hidden",
maxWidth: truncate.width,
...props.style,
}
: props.style
}
>
{props.children}
</span>
+13 -5
View File
@@ -23,14 +23,22 @@ export default function Stack({ gap, componentRef, ...props }: Props) {
className={twMerge(
"flex flex-col items-start gap-4",
props.center && "items-center",
gap
? typeof gap == "string"
? `gap-[${gap}]`
: `gap-${gap}`
: "",
"twui-stack",
props.className,
)}
style={
gap !== undefined
? {
// Inline style: dynamic `gap-${gap}` classes are
// invisible to Tailwind's class scanner
gap:
typeof gap == "number"
? `calc(var(--spacing) * ${gap})`
: gap,
...props.style,
}
: props.style
}
ref={componentRef}
>
{props.children}
+3 -4
View File
@@ -65,9 +65,10 @@ export default function MarkdownEditor({
{sideBySide ? (
<Row
className={twMerge(
`w-full grid xl:grid-cols-2 gap-6 max-h-[${maxHeight}]`,
"w-full grid xl:grid-cols-2 gap-6",
"overflow-auto",
)}
style={{ maxHeight }}
>
<MarkdownEditorComponent
setValue={setValue}
@@ -81,9 +82,7 @@ export default function MarkdownEditor({
/>
</Row>
) : (
<Stack
className={twMerge(`w-full max-h-[${maxHeight}] h-full`)}
>
<Stack className="w-full h-full" style={{ maxHeight }}>
{preview ? (
<MarkdownEditorPreviewComponent
value={value}
+1 -2
View File
@@ -21,8 +21,7 @@ export default function MarkdownEditorComponent({
setValue(newValue);
}}
wrapperProps={{
style: { height: maxHeight },
className: `max-h-[${maxHeight}]`,
style: { height: maxHeight, maxHeight },
}}
placeholder="## Write Some markdown ..."
fontSize="14px"
@@ -38,8 +38,9 @@ export default function MarkdownEditorPreviewComponent({
try {
// const { data, content } = matter(value);
// Strip leading front matter only (not `---` rules in the body)
const parsedValue = value
.replace(/---([^(---)]+)---/, "")
.replace(/^\s*---\r?\n[\s\S]*?\r?\n---[ \t]*(\r?\n|$)/, "")
.trim();
serialize(parsedValue, {
@@ -62,10 +63,15 @@ export default function MarkdownEditorPreviewComponent({
<Border
{...wrapperProps}
className={twMerge(
`w-full max-h-[${maxHeight}] h-[${maxHeight}] block px-6 pb-10`,
"w-full block px-6 pb-10",
"overflow-auto",
wrapperProps?.className,
)}
style={{
maxHeight,
height: maxHeight,
...wrapperProps?.style,
}}
>
{mdxSource ? (
<MDXRemote
+82
View File
@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import fetchApi from "./fetchApi";
const originalFetch = globalThis.fetch;
let calls: { url: string; init?: RequestInit }[] = [];
beforeEach(() => {
calls = [];
globalThis.fetch = (async (url: string, init?: RequestInit) => {
calls.push({ url, init });
return new Response(JSON.stringify({ success: true }));
}) as typeof fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("fetchApi", () => {
test("JSON-encodes object bodies without mutating the options", async () => {
const options = { method: "POST" as const, body: { a: 1 } };
await fetchApi("/api", options);
expect(calls[0]?.init?.body).toBe(JSON.stringify({ a: 1 }));
expect(options.body).toEqual({ a: 1 });
});
test("caller headers override the default Content-Type", async () => {
await fetchApi("/api", {
method: "POST",
body: "raw",
headers: { "content-type": "text/plain" } as any,
});
const headers = calls[0]?.init?.headers as Record<string, string>;
expect(headers["content-type"]).toBe("text/plain");
expect(headers["Content-Type"]).toBeUndefined();
});
test("sends FormData as-is and lets the browser set Content-Type", async () => {
const formData = new FormData();
formData.append("file", "content");
await fetchApi("/api", { method: "POST", body: formData });
const headers = calls[0]?.init?.headers as Record<string, string>;
expect(calls[0]?.init?.body).toBe(formData);
expect(headers["Content-Type"]).toBeUndefined();
});
test("uppercases the method and applies fetchOptions", async () => {
await fetchApi("/api", {
method: "patch",
fetchOptions: { credentials: "include" },
});
expect(calls[0]?.init?.method).toBe("PATCH");
expect(calls[0]?.init?.credentials).toBe("include");
});
test("adds the CSRF header and serializes the query", async () => {
await fetchApi("/api", {
method: "GET",
csrfKey: "x-csrf",
csrfValue: "token",
query: { page: 2 },
});
const headers = calls[0]?.init?.headers as Record<string, string>;
expect(calls[0]?.url).toBe("/api?page=2");
expect(headers["x-csrf"]).toBe("token");
});
test("returns an error object when the request fails", async () => {
globalThis.fetch = (async () => {
throw new Error("Network down");
}) as unknown as typeof fetch;
const res = await fetchApi("/api", { method: "GET" });
expect(res).toEqual({ success: false, msg: "Network down" });
});
});
+44 -20
View File
@@ -1,4 +1,3 @@
import _ from "lodash";
import twuiSerializeQuery from "../serialize-query";
export const FetchAPIMethods = [
@@ -16,7 +15,7 @@ export const FetchAPIMethods = [
type FetchApiOptions<T extends { [k: string]: any } = { [k: string]: any }> = {
method: (typeof FetchAPIMethods)[number];
body?: T | string;
body?: T | string | BodyInit;
headers?: FetchHeader;
query?: T;
csrfValue?: string;
@@ -35,6 +34,17 @@ export type FetchApiReturn = {
[key: string]: any;
};
function isNativeBody(body: unknown): body is BodyInit {
return (
(typeof FormData !== "undefined" && body instanceof FormData) ||
(typeof Blob !== "undefined" && body instanceof Blob) ||
(typeof URLSearchParams !== "undefined" &&
body instanceof URLSearchParams) ||
body instanceof ArrayBuffer ||
ArrayBuffer.isView(body)
);
}
/**
* # Fetch API
*/
@@ -52,6 +62,19 @@ export default async function fetchApi<
finalHeaders[options.csrfKey] = options.csrfValue;
}
if (options?.headers) {
for (const [key, value] of Object.entries(options.headers)) {
if (value === null || value === undefined) continue;
// Header names are case-insensitive; caller values win
for (const existingKey of Object.keys(finalHeaders)) {
if (existingKey.toLowerCase() === key.toLowerCase()) {
delete finalHeaders[existingKey];
}
}
finalHeaders[key] = value;
}
}
let finalURL = url;
if (options?.query) {
@@ -82,28 +105,29 @@ export default async function fetchApi<
}
} else if (typeof options === "object") {
try {
let fetchData;
let body: BodyInit | undefined;
if (options.body && typeof options.body === "object") {
let oldOptionsBody = _.cloneDeep(options.body);
options.body = JSON.stringify(oldOptionsBody);
}
if (options.headers) {
options.headers = _.merge(options.headers, finalHeaders);
const finalOptions: any = { ...options };
fetchData = await fetch(finalURL, finalOptions);
if (isNativeBody(options.body)) {
body = options.body;
// Let the browser set the multipart boundary etc.
if (finalHeaders["Content-Type"] === "application/json") {
delete finalHeaders["Content-Type"];
}
} else if (options.body && typeof options.body === "object") {
body = JSON.stringify(options.body);
} else {
const finalOptions = {
...options,
headers: finalHeaders,
} as RequestInit;
fetchData = await fetch(finalURL, finalOptions);
body = options.body as BodyInit | undefined;
}
const finalOptions: RequestInit = {
...options.fetchOptions,
method: options.method?.toUpperCase(),
headers: finalHeaders as HeadersInit,
body,
};
const fetchData = await fetch(finalURL, finalOptions);
data = await fetchData.json();
} catch (error: any) {
console.log("FetchAPI error #2:", error.message);
+3 -3
View File
@@ -23,7 +23,7 @@ export default async function fileInputToBase64({
return { fileName: inputFile.name };
}
let fileName = inputFile.name?.replace(/\..*/, "");
let fileName = inputFile.name?.replace(/\.[^.]*$/, "");
const file = inputFile;
try {
@@ -34,8 +34,8 @@ export default async function fileInputToBase64({
reader.onload = function () {
resolve(reader.result?.toString());
};
reader.onerror = function (/** @type {*} */ error: any) {
console.log("File Input to Base64 Error: ", error.message);
reader.onerror = function () {
reject(reader.error);
};
}
);
+1 -1
View File
@@ -25,7 +25,7 @@ export default async function imageInputToBase64({
throw new Error("No Files found");
}
let imageName = finalFile.name.replace(/\..*/, "");
let imageName = finalFile.name.replace(/\.[^.]*$/, "");
const MIME_TYPE = mimeType ? mimeType : finalFile.type;
const QUALITY = 0.95;
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, test } from "bun:test";
import twuiLinkMatchesPath from "./link-matches-path";
describe("twuiLinkMatchesPath", () => {
test("matches the exact path", () => {
expect(twuiLinkMatchesPath({ href: "/blog", pathname: "/blog" })).toBe(
true,
);
});
test("matches sub-paths when not strict", () => {
expect(
twuiLinkMatchesPath({ href: "/blog", pathname: "/blog/post-1" }),
).toBe(true);
});
test("does not match sub-paths when strict", () => {
expect(
twuiLinkMatchesPath({
href: "/blog",
pathname: "/blog/post-1",
strict: true,
}),
).toBe(false);
});
test("does not match a path that only shares a prefix", () => {
expect(
twuiLinkMatchesPath({ href: "/blog", pathname: "/blogging" }),
).toBe(false);
});
test("root link only matches the root path", () => {
expect(twuiLinkMatchesPath({ href: "/", pathname: "/" })).toBe(true);
expect(twuiLinkMatchesPath({ href: "/", pathname: "/about" })).toBe(
false,
);
});
test("ignores trailing slashes, query strings and hashes", () => {
expect(
twuiLinkMatchesPath({ href: "/docs/?tab=1#top", pathname: "/docs" }),
).toBe(true);
expect(twuiLinkMatchesPath({ href: "/docs", pathname: "/docs/" })).toBe(
true,
);
});
test("never matches links to other origins", () => {
expect(
twuiLinkMatchesPath({
href: "https://example.com/blog",
pathname: "/blog",
}),
).toBe(false);
});
test("does not match hash-only links", () => {
expect(
twuiLinkMatchesPath({ href: "#section", pathname: "/blog" }),
).toBe(false);
});
});
+45
View File
@@ -0,0 +1,45 @@
type Params = {
href: string;
pathname: string;
/** Only match the exact path, not its sub-paths */
strict?: boolean;
};
function normalizePath(path: string) {
const trimmed = path.replace(/\/+$/, "");
return trimmed || "/";
}
/**
* # Check whether a link points to the current page
* A non-strict link matches its own path and any sub-path, segment-wise:
* `/blog` matches `/blog` and `/blog/post`, but not `/blogging`. The root
* link `/` only ever matches `/`. Links to other origins never match.
*/
export default function twuiLinkMatchesPath({
href,
pathname,
strict,
}: Params): boolean {
if (href.startsWith("#")) return false;
let linkPath: string;
try {
const origin =
typeof window !== "undefined"
? window.location.origin
: "http://twui.local";
const url = new URL(href, origin);
if (url.origin !== origin) return false;
linkPath = normalizePath(url.pathname);
} catch {
return false;
}
const currentPath = normalizePath(pathname);
if (strict || linkPath === "/") return linkPath === currentPath;
return currentPath === linkPath || currentPath.startsWith(`${linkPath}/`);
}
+5
View File
@@ -57,4 +57,9 @@ describe("twuiSerializeQuery", () => {
const result = twuiSerializeQuery({ active: true as any });
expect(result).toContain("active=true");
});
test("keeps zero values", () => {
const result = twuiSerializeQuery({ page: 0, foo: "bar" });
expect(result).toContain("page=0");
});
});
+1 -1
View File
@@ -24,8 +24,8 @@ export default function twuiSerializeQuery(query: any): string {
const queryArr: string[] = [];
keys.forEach((key) => {
if (!key || !query[key]) return;
const value = query[key];
if (!key || (!value && value !== 0)) return;
if (typeof value === "object") {
const jsonStr = TWUIEJSON.stringify(value);
+4
View File
@@ -69,4 +69,8 @@ describe("twuiSlugify", () => {
test("handles number-only input", () => {
expect(twuiSlugify("123")).toBe("123");
});
test("trims leading and trailing underscore dividers", () => {
expect(twuiSlugify("!Hello World!", "_")).toBe("hello_world");
});
});
+2 -2
View File
@@ -23,13 +23,13 @@ export default function twuiSlugify(
.replace(/ /g, finalSlugDivider)
.replace(/[^a-z0-9]/g, finalSlugDivider)
.replace(/-{2,}|_{2,}/g, finalSlugDivider)
.replace(/^-/, "");
.replace(/^[-_]/, "");
if (allowTrailingDash) {
return finalStr;
}
return finalStr.replace(/-$/, "");
return finalStr.replace(/[-_]$/, "");
} catch (error: any) {
return "";
}