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>` - **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.
- Use `bun test` instead of `jest` or `vitest` - **Runtime/tooling:** Bun, not Node. Use `bun install`, `bun run <script>`, `bun test`, `bunx`. Never `npm`/`npx`/`yarn`/`pnpm`.
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild` - **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.
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` - **Styling:** Tailwind v4 via `src/base.css` (`@import "tailwindcss"`, `@source "./"`, `@theme inline` tokens). `tailwind-merge` (`twMerge`) for class composition.
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>` - **Icons:** `lucide-react` (peer dependency).
- Bun automatically loads .env, so don't use dotenv.
## APIs ## Commands
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`. | Command | What it does |
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`. | ------------------- | ------------------------------------------------------------------- |
- `Bun.redis` for Redis. Don't use `ioredis`. | `bun run typecheck` | `tsc --noEmit` - must be clean before finishing any change |
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`. | `bun test src` | Unit tests (`src/**/*.test.ts`) |
- `WebSocket` is built-in. Don't use `ws`. | `bun run compile` | Clean build into `dist/` + post-compile step |
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile | `./publish.sh "msg"`| Typecheck → test → compile → `git commit/push` → `bun publish` |
- Bun.$`ls` instead of execa.
## 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 | Path | Contents |
import { test, expect } from "bun:test"; | ----------------------------------------- | ------------------------------------------------------------------------ |
| `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", () => { ## Public API Rules (MANDATORY)
expect(1).toBe(1);
});
```
## 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 ## Conventions
import index from "./index.html"
Bun.serve({ - **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.
routes: { - **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.
"/": index, - **Merge classes with `twMerge`**, with the caller's `className` last so it wins.
"/api/users/:id": { - **Constant unions** are exported as `as const` arrays plus a derived type (`TWUIButtonColors` / `TWUIButtonColorType`).
GET: (req) => { - **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.
return new Response(JSON.stringify({ id: req.params.id })); - **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.
},
// 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,
}
})
```
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 1. `bun run typecheck` and `bun test src` pass.
<html> 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.
<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`.
@@ -14,6 +14,10 @@ export default function twuiGrabPopoverStyles({
}: Params): React.CSSProperties { }: Params): React.CSSProperties {
if (!targetElRef.current) return {}; if (!targetElRef.current) return {};
const rect = targetElRef.current.getBoundingClientRect(); 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); const targetElCurrStyles = window.getComputedStyle(targetElRef.current);
@@ -31,7 +35,7 @@ export default function twuiGrabPopoverStyles({
}; };
const defaultTopStyleStyle: React.CSSProperties = { 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, left: rect.left + window.scrollX + rect.width / 2,
transform: "translateX(-50%)", transform: "translateX(-50%)",
}; };
@@ -43,7 +47,7 @@ export default function twuiGrabPopoverStyles({
popoverStyle, popoverStyle,
_.omit(defaultBottomStyle, ["transform"]), _.omit(defaultBottomStyle, ["transform"]),
{ {
left: rect.left, left: rect.left + window.scrollX,
} as React.CSSProperties } as React.CSSProperties
); );
} else if (position === "bottom-right") { } else if (position === "bottom-right") {
@@ -52,7 +56,7 @@ export default function twuiGrabPopoverStyles({
_.omit(defaultBottomStyle, ["left", "transform"]), _.omit(defaultBottomStyle, ["left", "transform"]),
{ {
right: right:
window.innerWidth - viewportWidth -
(rect.left + window.scrollX) - (rect.left + window.scrollX) -
rect.width - rect.width -
targetElRightPadding, targetElRightPadding,
@@ -66,8 +70,7 @@ export default function twuiGrabPopoverStyles({
popoverStyle.transform = "translateY(-50%)"; popoverStyle.transform = "translateY(-50%)";
} else if (position === "left") { } else if (position === "left") {
popoverStyle.top = rect.top + window.scrollY + rect.height / 2; popoverStyle.top = rect.top + window.scrollY + rect.height / 2;
popoverStyle.right = popoverStyle.right = viewportWidth - (rect.left + window.scrollX) + 8;
window.innerWidth - (rect.left + window.scrollX) + 8;
popoverStyle.transform = "translateY(-50%)"; popoverStyle.transform = "translateY(-50%)";
} }
+23 -1
View File
@@ -21,6 +21,17 @@ export default function ModalComponent({
no_cancel_button, no_cancel_button,
...props ...props
}: 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; if (!open) return null;
return ReactDOM.createPortal( return ReactDOM.createPortal(
@@ -43,7 +54,18 @@ export default function ModalComponent({
}} }}
></div> ></div>
<Paper <Paper
{..._.omit(props, ["targetWrapperProps"])} {..._.omit(props, [
"target",
"targetRef",
"targetWrapperProps",
"popoverReferenceRef",
"isPopover",
"position",
"trigger",
"debounce",
"onClose",
"hoverOpen",
])}
className={twMerge( className={twMerge(
"z-10 max-w-modal bg-background-light dark:bg-background-dark", "z-10 max-w-modal bg-background-light dark:bg-background-dark",
"w-full relative max-h-[95vh] overflow-y-auto", "w-full relative max-h-[95vh] overflow-y-auto",
+8 -3
View File
@@ -30,8 +30,6 @@ export default function PopoverComponent({
isPopover, isPopover,
...props ...props
}: Props) { }: Props) {
if (!open) return null;
const [style, setStyle] = React.useState({}); const [style, setStyle] = React.useState({});
React.useEffect(() => { React.useEffect(() => {
@@ -68,7 +66,14 @@ export default function PopoverComponent({
return ReactDOM.createPortal( return ReactDOM.createPortal(
<Paper <Paper
{...props} {..._.omit(props, [
"target",
"targetRef",
"targetWrapperProps",
"onClose",
"hoverOpen",
"no_cancel_button",
])}
className={twMerge( className={twMerge(
"max-w-[300px] z-[250]", "max-w-[300px] z-[250]",
"twui-popover-content", "twui-popover-content",
+14 -6
View File
@@ -10,6 +10,7 @@ import Row from "../../layout/Row";
import Divider from "../../layout/Divider"; import Divider from "../../layout/Divider";
import { ChevronDown, Circle } from "lucide-react"; import { ChevronDown, Circle } from "lucide-react";
import Button from "../../layout/Button"; import Button from "../../layout/Button";
import twuiLinkMatchesPath from "../../utils/link-matches-path";
type Props = DetailedHTMLProps< type Props = DetailedHTMLProps<
AnchorHTMLAttributes<HTMLAnchorElement>, AnchorHTMLAttributes<HTMLAnchorElement>,
@@ -44,16 +45,23 @@ export default function TWUIDocsLink({
React.useEffect(() => { React.useEffect(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const basePathMatch = window.location.pathname.includes( const basePathMatch = twuiLinkMatchesPath({
docLink.href, href: docLink.href,
); pathname: window.location.pathname,
});
const isStrictMatch = Boolean( const isStrictMatch = Boolean(
linkRef.current?.getAttribute("data-strict"), linkRef.current?.getAttribute("data-strict"),
); );
if (strict || isStrictMatch) { if (strict || isStrictMatch) {
setIsActive(window.location.pathname === docLink.href); setIsActive(
twuiLinkMatchesPath({
href: docLink.href,
pathname: window.location.pathname,
strict: true,
}),
);
} else { } else {
setIsActive(basePathMatch); setIsActive(basePathMatch);
} }
@@ -66,8 +74,8 @@ export default function TWUIDocsLink({
return ( return (
<Stack <Stack
className={twMerge("gap-2 w-full", wrapperProps?.className)}
{...wrapperProps} {...wrapperProps}
className={twMerge("gap-2 w-full", wrapperProps?.className)}
> >
<Row className="flex-nowrap grow justify-between w-full"> <Row className="flex-nowrap grow justify-between w-full">
{child && <Circle size={6} />} {child && <Circle size={6} />}
@@ -105,11 +113,11 @@ export default function TWUIDocsLink({
{docLink.children && expand && ( {docLink.children && expand && (
<Row className="items-stretch gap-4 grow w-full flex-nowrap"> <Row className="items-stretch gap-4 grow w-full flex-nowrap">
<Stack <Stack
{...childWrapperProps}
className={twMerge( className={twMerge(
"gap-2 w-full pl-3", "gap-2 w-full pl-3",
childWrapperProps?.className, childWrapperProps?.className,
)} )}
{...childWrapperProps}
> >
{docLink.children.map((link, index) => ( {docLink.children.map((link, index) => (
<TWUIDocsLink <TWUIDocsLink
@@ -116,6 +116,8 @@ export default function TWUIDocsRightAside({
headerHrefs.forEach((headerHref) => { headerHrefs.forEach((headerHref) => {
observer.observe(headerHref); observer.observe(headerHref);
}); });
return () => observer.disconnect();
}, [links]); }, [links]);
React.useEffect(() => { React.useEffect(() => {
+4 -4
View File
@@ -24,8 +24,6 @@ export type AceEditorComponentType = {
showLabel?: boolean; showLabel?: boolean;
}; };
let timeout: any;
/** /**
* # Powerful Ace Editor * # Powerful Ace Editor
* @note **NOTE** head scripts required * @note **NOTE** head scripts required
@@ -51,6 +49,7 @@ export default function AceEditor({
try { try {
const editorElementRef = React.useRef<HTMLDivElement>(undefined); const editorElementRef = React.useRef<HTMLDivElement>(undefined);
const editorRefInstance = React.useRef<AceAjax.Editor>(undefined); const editorRefInstance = React.useRef<AceAjax.Editor>(undefined);
const changeTimeoutRef = React.useRef<any>(null);
const [refresh, setRefresh] = React.useState(0); const [refresh, setRefresh] = React.useState(0);
const [darkMode, setDarkMode] = React.useState(false); const [darkMode, setDarkMode] = React.useState(false);
@@ -106,9 +105,9 @@ export default function AceEditor({
editor.getSession().on("change", function (e) { editor.getSession().on("change", function (e) {
if (onChange) { if (onChange) {
clearTimeout(timeout); window.clearTimeout(changeTimeoutRef.current);
setTimeout(() => { changeTimeoutRef.current = setTimeout(() => {
try { try {
onChange( onChange(
editor.getValue(), editor.getValue(),
@@ -123,6 +122,7 @@ export default function AceEditor({
if (editorRef) editorRef.current = editor; if (editorRef) editorRef.current = editor;
return function () { return function () {
window.clearTimeout(changeTimeoutRef.current);
editor.destroy(); editor.destroy();
}; };
}, [refresh, darkMode, ready, mode]); }, [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 { tinyMCE } = useTinyMCE({ base_url });
const editorComponentRef = React.useRef<HTMLDivElement>(null); const editorComponentRef = React.useRef<HTMLDivElement>(null);
const internalEditorRef = React.useRef<Editor | null>(null);
const editorRef: RefObject<Editor | 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; const EDITOR_VALUE_CHANGE_TIMEOUT = 500;
@@ -79,21 +83,21 @@ export default function TinyMCEEditor<KeyType extends string>({
}, 200); }, 200);
}, [tinyMCE]); }, [tinyMCE]);
let valueTimeout: any; // Stable across renders (and works outside secure contexts, unlike
// crypto.randomUUID) so cleanup can find the editor instance by id
const id = crypto.randomUUID(); const id = `twui-tinymce-${React.useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
const updateContent = useCallback((editor: Editor) => { const updateContent = useCallback((editor: Editor) => {
if (changeHandler) { if (!changeHandlerRef.current) return;
window.clearTimeout(valueTimeout);
valueTimeout = setTimeout(() => { window.clearTimeout(valueTimeoutRef.current);
changeHandler(
editor.getContent(), valueTimeoutRef.current = setTimeout(() => {
editorRef.current || undefined, changeHandlerRef.current?.(
); editor.getContent(),
}, EDITOR_VALUE_CHANGE_TIMEOUT); editorRef.current || undefined,
} );
}, EDITOR_VALUE_CHANGE_TIMEOUT);
}, []); }, []);
React.useEffect(() => { React.useEffect(() => {
@@ -105,7 +109,7 @@ export default function TinyMCEEditor<KeyType extends string>({
base_url || base_url ||
getTinyMCEDefaultBaseUrl(); getTinyMCEDefaultBaseUrl();
tinyMCE.init({ const initPromise = tinyMCE.init({
height: FINAL_HEIGHT, height: FINAL_HEIGHT,
menubar: false, menubar: false,
plugins: plugins:
@@ -154,12 +158,12 @@ export default function TinyMCEEditor<KeyType extends string>({
}); });
return function () { return function () {
if (!ready) return; window.clearTimeout(valueTimeoutRef.current);
tinyMCE?.get(id)?.remove();
const instance = editorComponentRef.current // Init is async: also remove editors that finish after cleanup
? tinyMCE?.get(editorComponentRef.current?.id) initPromise
: undefined; ?.then((editors) => editors.forEach((editor) => editor.remove()))
instance?.remove(); .catch(() => {});
}; };
}, [tinyMCE, themeReady, refresh, ...(refreshDependencyArray || [])]); }, [tinyMCE, themeReady, refresh, ...(refreshDependencyArray || [])]);
+14 -3
View File
@@ -2,8 +2,6 @@ import React from "react";
import type { TinyMCE } from "./tinymce"; import type { TinyMCE } from "./tinymce";
import getTinyMCEDefaultBaseUrl from "./default-base-url"; import getTinyMCEDefaultBaseUrl from "./default-base-url";
let interval: any;
type Params = { type Params = {
base_url?: string; base_url?: string;
}; };
@@ -29,7 +27,20 @@ export default function useTinyMCE(params?: Params) {
params?.base_url || params?.base_url ||
getTinyMCEDefaultBaseUrl(); 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.async = true;
script.onload = () => { script.onload = () => {
+231 -228
View File
@@ -1,228 +1,231 @@
import React, { type ComponentProps, type ReactNode } from "react"; import React, { type ComponentProps, type ReactNode } from "react";
import Link from "../layout/Link"; import Link from "../layout/Link";
import Divider from "../layout/Divider"; import Divider from "../layout/Divider";
import Row from "../layout/Row"; import Row from "../layout/Row";
import lowerToTitleCase from "../utils/lower-to-title-case"; import lowerToTitleCase from "../utils/lower-to-title-case";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { ChevronLeft } from "lucide-react"; import { ChevronLeft } from "lucide-react";
import Button from "../layout/Button"; import Button from "../layout/Button";
type LinkObject = { type LinkObject = {
title: string; title: string;
path: string; path: string;
}; };
type Props = { type Props = {
excludeRegexMatch?: RegExp; excludeRegexMatch?: RegExp;
linkProps?: ComponentProps<typeof Link>; linkProps?: ComponentProps<typeof Link>;
currentLinkProps?: ComponentProps<typeof Link>; currentLinkProps?: ComponentProps<typeof Link>;
dividerProps?: ComponentProps<typeof Divider>; dividerProps?: ComponentProps<typeof Divider>;
backButtonProps?: ComponentProps<typeof Button>; backButtonProps?: ComponentProps<typeof Button>;
backButton?: boolean; backButton?: boolean;
pageUrl?: string; pageUrl?: string;
currentTitle?: string; currentTitle?: string;
skipHome?: boolean; skipHome?: boolean;
divider?: ReactNode; divider?: ReactNode;
}; };
/** /**
* # TWUI Breadcrumbs * # TWUI Breadcrumbs
* @className `twui-breadcrumb-link` * @className `twui-breadcrumb-link`
* @className `twui-current-breadcrumb-wrapper` * @className `twui-current-breadcrumb-wrapper`
* @className `twui-breadcrumbs-divider` * @className `twui-breadcrumbs-divider`
* @className `twui-breadcrumbs-back-button` * @className `twui-breadcrumbs-back-button`
*/ */
export default function Breadcrumbs({ export default function Breadcrumbs({
excludeRegexMatch, excludeRegexMatch,
linkProps, linkProps,
currentLinkProps, currentLinkProps,
dividerProps, dividerProps,
backButton, backButton,
backButtonProps, backButtonProps,
pageUrl, pageUrl,
currentTitle, currentTitle,
skipHome, skipHome,
divider, divider,
}: Props) { }: Props) {
const [links, setLinks] = React.useState<LinkObject[] | null>( const [links, setLinks] = React.useState<LinkObject[] | null>(
pageUrl pageUrl
? twuiBreadcrumbsGenerateLinksFromUrl({ url: pageUrl, skipHome }) ? twuiBreadcrumbsGenerateLinksFromUrl({
: null, url: pageUrl,
); excludeRegexMatch,
skipHome,
React.useEffect(() => { })
if (links) return; : null,
);
let pathname = window.location.pathname;
React.useEffect(() => {
let validPathLinks = twuiBreadcrumbsGenerateLinksFromUrl({ if (links) return;
url: pathname,
excludeRegexMatch, let pathname = window.location.pathname;
skipHome,
}); let validPathLinks = twuiBreadcrumbsGenerateLinksFromUrl({
url: pathname,
setLinks(validPathLinks); excludeRegexMatch,
skipHome,
return function () { });
setLinks(null);
}; setLinks(validPathLinks);
}, []);
return function () {
if (!links?.[1]) { setLinks(null);
return <React.Fragment></React.Fragment>; };
} }, []);
return ( if (!links?.[1]) {
<nav return <React.Fragment></React.Fragment>;
className={twMerge( }
"overflow-x-auto",
"twui-current-breadcrumb-wrapper", return (
)} <nav
aria-label="Breadcrumb" className={twMerge(
> "overflow-x-auto",
<Row "twui-current-breadcrumb-wrapper",
className={twMerge( )}
"gap-4 flex-nowrap whitespace-nowrap overflow-x-auto overflow-y-hidden w-full", aria-label="Breadcrumb"
)} >
> <Row
{backButton && ( className={twMerge(
<React.Fragment> "gap-4 flex-nowrap whitespace-nowrap overflow-x-auto overflow-y-hidden w-full",
<Button )}
variant="ghost" >
color="gray" {backButton && (
{...backButtonProps} <React.Fragment>
className={twMerge( <Button
"p-1 -my-2 -mx-2", variant="ghost"
"twui-breadcrumbs-back-button", color="gray"
backButtonProps?.className, {...backButtonProps}
)} className={twMerge(
onClick={(e) => { "p-1 -my-2 -mx-2",
window.history.back(); "twui-breadcrumbs-back-button",
backButtonProps?.onClick?.(e); backButtonProps?.className,
}} )}
title="Breadcrumbs Back Button" onClick={(e) => {
beforeIcon={<ChevronLeft size={20} />} window.history.back();
/> backButtonProps?.onClick?.(e);
{divider || ( }}
<Divider title="Breadcrumbs Back Button"
vertical beforeIcon={<ChevronLeft size={20} />}
className={twMerge( />
"twui-breadcrumbs-divider", {divider || (
dividerProps?.className, <Divider
)} vertical
/> className={twMerge(
)} "twui-breadcrumbs-divider",
</React.Fragment> dividerProps?.className,
)} )}
/>
{links.map((linkObject, index, array) => { )}
const isTarget = array.length - 1 == index; </React.Fragment>
)}
if (index === links.length - 1) {
return ( {links.map((linkObject, index, array) => {
<Link const isTarget = array.length - 1 == index;
key={index}
href={linkObject.path} if (index === links.length - 1) {
{...linkProps} return (
{...(isTarget ? currentLinkProps : {})} <Link
className={twMerge( key={index}
"text-primary-text/50 dark:text-primary-dark-text/50 text-xs", href={linkObject.path}
"max-w-[200px] text-ellipsis overflow-hidden", {...linkProps}
isTarget ? "current" : "", {...(isTarget ? currentLinkProps : {})}
"twui-breadcrumb-link", className={twMerge(
linkProps?.className, "text-primary-text/50 dark:text-primary-dark-text/50 text-xs",
isTarget && currentLinkProps?.className, "max-w-[200px] text-ellipsis overflow-hidden",
)} isTarget ? "current" : "",
title={ "twui-breadcrumb-link",
currentLinkProps?.title || linkObject.title linkProps?.className,
} isTarget && currentLinkProps?.className,
> )}
{currentTitle || linkObject.title} title={
</Link> currentLinkProps?.title || linkObject.title
); }
} else { >
return ( {currentTitle || linkObject.title}
<React.Fragment key={index}> </Link>
<Link );
href={linkObject.path} } else {
{...linkProps} return (
{...(isTarget ? currentLinkProps : {})} <React.Fragment key={index}>
className={twMerge( <Link
"text-xs", href={linkObject.path}
isTarget ? "current" : "", {...linkProps}
"twui-breadcrumb-link", {...(isTarget ? currentLinkProps : {})}
linkProps?.className, className={twMerge(
isTarget && currentLinkProps?.className, "text-xs",
)} isTarget ? "current" : "",
> "twui-breadcrumb-link",
{currentLinkProps?.title || linkProps?.className,
linkObject.title} isTarget && currentLinkProps?.className,
</Link> )}
{divider || ( >
<Divider {linkObject.title}
vertical </Link>
{...dividerProps} {divider || (
className={twMerge( <Divider
"twui-breadcrumbs-divider", vertical
dividerProps?.className, {...dividerProps}
)} className={twMerge(
/> "twui-breadcrumbs-divider",
)} dividerProps?.className,
</React.Fragment> )}
); />
} )}
})} </React.Fragment>
</Row> );
</nav> }
); })}
//////////////////////////////////////// </Row>
//////////////////////////////////////// </nav>
//////////////////////////////////////// );
} ////////////////////////////////////////
////////////////////////////////////////
export function twuiBreadcrumbsGenerateLinksFromUrl({ ////////////////////////////////////////
url, }
excludeRegexMatch,
skipHome, export function twuiBreadcrumbsGenerateLinksFromUrl({
}: { url,
url: string; excludeRegexMatch,
excludeRegexMatch?: RegExp; skipHome,
skipHome?: boolean; }: {
}) { url: string;
let pathLinks = url.split("/"); excludeRegexMatch?: RegExp;
skipHome?: boolean;
let validPathLinks = []; }) {
let pathLinks = url.split("/");
if (!skipHome) {
validPathLinks.push({ let validPathLinks = [];
title: "Home",
path: url.match(/admin/) ? "/admin" : "/", if (!skipHome) {
}); validPathLinks.push({
} title: "Home",
path: url.match(/admin/) ? "/admin" : "/",
pathLinks.forEach((linkText, index, array) => { });
if (!linkText?.match(/./)) { }
return;
} pathLinks.forEach((linkText, index, array) => {
if (!linkText?.match(/./)) {
if (excludeRegexMatch && excludeRegexMatch.test(linkText)) return; return;
}
validPathLinks.push({
title: lowerToTitleCase(linkText), if (excludeRegexMatch && excludeRegexMatch.test(linkText)) return;
path: (() => {
let path = ""; validPathLinks.push({
title: lowerToTitleCase(linkText),
for (let i = 0; i < array.length; i++) { path: (() => {
const lnText = array[i]; let path = "";
if (i > index || !lnText?.match(/./)) continue;
for (let i = 0; i < array.length; i++) {
path += `/${lnText}`; const lnText = array[i];
} if (i > index || !lnText?.match(/./)) continue;
return path; path += `/${lnText}`;
})(), }
});
}); return path;
})(),
return validPathLinks; });
} });
return validPathLinks;
}
+14
View File
@@ -106,6 +106,11 @@ export default function Dropdown({
React.useEffect(() => { React.useEffect(() => {
setMounted(true); setMounted(true);
return () => {
window.clearTimeout(timeoutRef.current);
window.clearTimeout(openTimeoutRef.current);
};
}, []); }, []);
/** /**
@@ -146,6 +151,15 @@ export default function Dropdown({
case "bottom-right": case "bottom-right":
style = { right: viewportWidth - rect.right, top: rect.bottom }; style = { right: viewportWidth - rect.right, top: rect.bottom };
break; 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": case "top":
style = { style = {
left: centerX, left: centerX,
+7 -10
View File
@@ -6,6 +6,7 @@ import React, {
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import Row from "../layout/Row"; import Row from "../layout/Row";
import HeaderNavLinkComponent from "./HeaderNavLinkComponent"; import HeaderNavLinkComponent from "./HeaderNavLinkComponent";
import twuiLinkMatchesPath from "../utils/link-matches-path";
export type TWUI_HEADER_NAV_PROPS = DetailedHTMLProps< export type TWUI_HEADER_NAV_PROPS = DetailedHTMLProps<
HTMLAttributes<HTMLElement>, HTMLAttributes<HTMLElement>,
@@ -84,17 +85,13 @@ export function twuiAddActiveLinksFn({
const linkAttr = linkEl.getAttribute("href"); const linkAttr = linkEl.getAttribute("href");
if (window.location.pathname === "/" && linkAttr == "/") { if (
linkEl.classList.add("active");
} else if (
isLinkStrict &&
linkEl.getAttribute("href") === window.location.pathname
) {
linkEl.classList.add("active");
} else if (
linkAttr && linkAttr &&
window.location.pathname.startsWith(linkAttr) && twuiLinkMatchesPath({
!isLinkStrict href: linkAttr,
pathname: window.location.pathname,
strict: Boolean(isLinkStrict),
})
) { ) {
linkEl.classList.add("active"); linkEl.classList.add("active");
} }
+9 -7
View File
@@ -57,6 +57,9 @@ export default function LinkList({
...props ...props
}: TWUI_LINK_LIST_PROPS) { }: TWUI_LINK_LIST_PROPS) {
const listRef = React.useRef<HTMLDivElement>(null); const listRef = React.useRef<HTMLDivElement>(null);
const finalLinks = links
.flat()
.filter((ln): ln is TWUI_LINK_LIST_LINK_OBJECT => Boolean(ln));
React.useEffect(() => { React.useEffect(() => {
twuiAddActiveLinksFn({ twuiAddActiveLinksFn({
@@ -75,12 +78,7 @@ export default function LinkList({
props.className, props.className,
)} )}
> >
{links {finalLinks.map((link, index) => {
.flat()
.filter((ln) => Boolean(ln))
.map((link, index) => {
if (!link) return null;
if (link.divider) if (link.divider)
return ( return (
<React.Fragment key={index}> <React.Fragment key={index}>
@@ -89,7 +87,7 @@ export default function LinkList({
); );
const finalDivider = const finalDivider =
index < links.length - 1 && index < finalLinks.length - 1 &&
(dividerComponent ? ( (dividerComponent ? (
dividerComponent dividerComponent
) : divider ? ( ) : divider ? (
@@ -107,9 +105,12 @@ export default function LinkList({
className={twMerge( className={twMerge(
"p-2 cursor-pointer whitespace-nowrap", "p-2 cursor-pointer whitespace-nowrap",
linkProps?.className, linkProps?.className,
buttonProps?.className,
link.buttonProps?.className,
)} )}
onClick={(e) => { onClick={(e) => {
link.onClick?.(e); link.onClick?.(e);
buttonProps?.onClick?.(e);
link.buttonProps?.onClick?.(e); link.buttonProps?.onClick?.(e);
}} }}
> >
@@ -138,6 +139,7 @@ export default function LinkList({
strict={link.strict} strict={link.strict}
onClick={(e) => { onClick={(e) => {
link.onClick?.(e); link.onClick?.(e);
linkProps?.onClick?.(e);
link.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(() => { React.useEffect(() => {
existingSetOpen?.(open); existingSetOpen?.(open);
if (open == false) onClose?.(); if (wasOpenRef.current && !open) onClose?.();
wasOpenRef.current = open;
}, [open]); }, [open]);
React.useEffect(() => { React.useEffect(() => {
setOpen(existingOpen || false); setOpen(existingOpen || false);
}, [existingOpen]); }, [existingOpen]);
const finalTargetRef = targetRef || React.useRef<HTMLDivElement>(null); const internalTargetRef = React.useRef<HTMLDivElement>(null);
const finalTargetRef = targetRef || internalTargetRef;
const finalPopoverReferenceRef = popoverReferenceRef || finalTargetRef; const finalPopoverReferenceRef = popoverReferenceRef || finalTargetRef;
const popoverTargetActiveRef = React.useRef(false); const popoverTargetActiveRef = React.useRef(false);
+9 -13
View File
@@ -26,10 +26,10 @@ export default function Pagination({
limit, limit,
...props ...props
}: Props) { }: Props) {
if (!count || !page || !limit) if (!page || !limit)
return ( return <EmptyContent title={`page and limit are both required`} />;
<EmptyContent title={`count, page, and limit are all required`} />
); if (!count) return null;
const isLimit = limit * page >= count; const isLimit = limit * page >= count;
@@ -44,10 +44,8 @@ export default function Pagination({
}} }}
variant="outlined" variant="outlined"
size="small" size="small"
className={twMerge( className="p-1"
"p-1", disabled={isLimit}
isLimit ? "opacity-40 pointer-events-none" : "",
)}
> >
<ChevronRight size={20} /> <ChevronRight size={20} />
</Button> </Button>
@@ -63,17 +61,15 @@ export default function Pagination({
> >
{pages > 1 && ( {pages > 1 && (
<Button <Button
title="Next Page Button" title="Previous Page Button"
onClick={() => { onClick={() => {
window.scrollTo({ top: 0, behavior: "smooth" }); window.scrollTo({ top: 0, behavior: "smooth" });
setPage?.((prev) => prev - 1); setPage?.((prev) => prev - 1);
}} }}
variant="outlined" variant="outlined"
size="small" size="small"
className={twMerge( className="p-1"
"p-1", disabled={page == 1}
page == 1 ? "opacity-40 pointer-events-none" : "",
)}
> >
<ChevronLeft size={20} /> <ChevronLeft size={20} />
</Button> </Button>
+57 -49
View File
@@ -1,49 +1,57 @@
import React from "react"; import React from "react";
import { serialize } from "next-mdx-remote/serialize"; import { serialize } from "next-mdx-remote/serialize";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import rehypePrismPlus from "rehype-prism-plus"; import rehypePrismPlus from "rehype-prism-plus";
import { MDXRemote, type MDXRemoteSerializeResult } from "next-mdx-remote"; import { MDXRemote, type MDXRemoteSerializeResult } from "next-mdx-remote";
import { useMDXComponents } from "../mdx/mdx-components"; import { useMDXComponents } from "../mdx/mdx-components";
export const TWUIPrismLanguages = ["shell", "javascript"] as const; export const TWUIPrismLanguages = ["shell", "javascript"] as const;
type Props = { type Props = {
content: string; content: string;
refresh?: number; refresh?: number;
}; };
/** /**
* # CodeBlock * # CodeBlock
* *
* @className `twui-remote-code-block-wrapper` * @className `twui-remote-code-block-wrapper`
*/ */
export default function RemoteCodeBlock({ content, refresh }: Props) { export default function RemoteCodeBlock({ content, refresh }: Props) {
const [mdxSource, setMdxSource] = const [mdxSource, setMdxSource] =
React.useState<MDXRemoteSerializeResult<any>>(); React.useState<MDXRemoteSerializeResult<any>>();
const { components } = useMDXComponents(); const { components } = useMDXComponents();
React.useEffect(() => { React.useEffect(() => {
serialize(content, { let cancelled = false;
mdxOptions: {
remarkPlugins: [remarkGfm], serialize(content, {
rehypePlugins: [rehypePrismPlus], mdxOptions: {
}, remarkPlugins: [remarkGfm],
}).then((mdxSrc) => { rehypePlugins: [rehypePrismPlus],
setMdxSource(mdxSrc); },
}); })
}, [refresh]); .then((mdxSrc) => {
if (!cancelled) setMdxSource(mdxSrc);
if (!mdxSource) { })
return null; .catch(() => {});
}
return () => {
return ( cancelled = true;
<MDXRemote };
{...mdxSource} }, [content, refresh]);
components={{
...components, if (!mdxSource) {
}} return null;
/> }
);
} return (
<MDXRemote
{...mdxSource}
components={{
...components,
}}
/>
);
}
+9 -6
View File
@@ -9,8 +9,6 @@ import React, {
type ReactNode, type ReactNode,
} from "react"; } from "react";
let timeout: any;
export type SearchProps<KeyType extends string> = DetailedHTMLProps< export type SearchProps<KeyType extends string> = DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>, React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement HTMLDivElement
@@ -50,16 +48,21 @@ export default function Search<KeyType extends string>({
props.defaultValue?.toString() || "", props.defaultValue?.toString() || "",
); );
React.useEffect(() => { const timeoutRef = React.useRef<any>(null);
clearTimeout(timeout);
timeout = setTimeout(() => { React.useEffect(() => {
window.clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
dispatch?.(input); dispatch?.(input);
changeHandler?.(input); changeHandler?.(input);
}, delay); }, delay);
return () => window.clearTimeout(timeoutRef.current);
}, [input]); }, [input]);
const inputRef = componentRef || React.useRef<HTMLInputElement>(null); const internalInputRef = React.useRef<HTMLInputElement>(null);
const inputRef = componentRef || internalInputRef;
// React.useEffect(() => { // React.useEffect(() => {
// if (props.autoFocus) { // if (props.autoFocus) {
+7 -6
View File
@@ -18,8 +18,6 @@ export type TWUI_STAR_RATING_PROPS = DetailedHTMLProps<
> & > &
StarProps; StarProps;
let timeout: any;
/** /**
* # Star Rating Component * # Star Rating Component
* @className_wrapper twui-star-rating * @className_wrapper twui-star-rating
@@ -41,12 +39,15 @@ export default function StarRating({
const starClicked = React.useRef(false); const starClicked = React.useRef(false);
const sectionHovered = React.useRef(false); const sectionHovered = React.useRef(false);
const timeoutRef = React.useRef<any>(null);
React.useEffect(() => { React.useEffect(() => {
window.clearTimeout(timeout); window.clearTimeout(timeoutRef.current);
timeout = setTimeout(() => { timeoutRef.current = setTimeout(() => {
setValueExternal?.(finalValue); setValueExternal?.(selectedStarValue);
}, 500); }, 500);
return () => window.clearTimeout(timeoutRef.current);
}, [selectedStarValue]); }, [selectedStarValue]);
return ( return (
@@ -148,6 +149,7 @@ function StarComponent({
> >
<Star <Star
size={size} size={size}
{...starProps}
className={twMerge( className={twMerge(
"text-slate-300 dark:text-white/20", "text-slate-300 dark:text-white/20",
isActive && 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", // "hover:text-orange-500 hover:dark:text-orange-400 hover:fill-orange-500 hover:dark:fill-orange-400",
starProps?.className, starProps?.className,
)} )}
{...starProps}
/> />
</div> </div>
); );
+14 -2
View File
@@ -6,6 +6,18 @@ type Props = {
data?: { [k: string]: any }[]; 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) { export default function Table({ data }: Props) {
if (!data || data.length === 0) { if (!data || data.length === 0) {
return ( return (
@@ -62,9 +74,9 @@ export default function Table({ data }: Props) {
"dark:text-foreground-dark max-w-[200px] overflow-hidden", "dark:text-foreground-dark max-w-[200px] overflow-hidden",
"overflow-ellipsis", "overflow-ellipsis",
)} )}
title={row[header]} title={formatCellValue(row[header])}
> >
{row[header]} {formatCellValue(row[header])}
</td> </td>
))} ))}
</tr> </tr>
+6 -8
View File
@@ -19,9 +19,6 @@ export type TWUIToastProps = DetailedHTMLProps<
color?: (typeof ToastStyles)[number]; color?: (typeof ToastStyles)[number];
}; };
let interval: any;
let timeout: any;
/** /**
* # Toast Component * # Toast Component
* @className twui-toast-root * @className twui-toast-root
@@ -38,6 +35,7 @@ export default function Toast({
...props ...props
}: TWUIToastProps) { }: TWUIToastProps) {
const [ready, setReady] = React.useState(false); const [ready, setReady] = React.useState(false);
const timeoutRef = React.useRef<any>(null);
const IDName = "twui-toast-root"; const IDName = "twui-toast-root";
React.useEffect(() => { React.useEffect(() => {
@@ -56,14 +54,13 @@ export default function Toast({
React.useEffect(() => { React.useEffect(() => {
if (!ready || !open) return; if (!ready || !open) return;
timeout = setTimeout(() => { timeoutRef.current = setTimeout(() => {
setOpen?.(false); setOpen?.(false);
closeDispatch?.(open); closeDispatch?.(open);
}, closeDelay); }, closeDelay);
return function () { return function () {
setOpen?.(false); window.clearTimeout(timeoutRef.current);
closeDispatch?.(open);
}; };
}, [ready, open]); }, [ready, open]);
@@ -85,10 +82,11 @@ export default function Toast({
"twui-toast", "twui-toast",
)} )}
onMouseEnter={() => { onMouseEnter={() => {
window.clearTimeout(timeout); window.clearTimeout(timeoutRef.current);
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
timeout = setTimeout(() => { window.clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
setOpen?.(false); setOpen?.(false);
closeDispatch?.(open); closeDispatch?.(open);
}, closeDelay); }, closeDelay);
@@ -90,6 +90,7 @@ export default function AIPromptActionSection({
loading={loading} loading={loading}
className="p-2" className="p-2"
onClick={() => { onClick={() => {
if (!prompt.trim()) return;
currentPromptRef.current = prompt; currentPromptRef.current = prompt;
setTimeout(() => { setTimeout(() => {
setPrompt(""); setPrompt("");
+1 -2
View File
@@ -28,9 +28,7 @@ export default function AIPromptBlock({
setMdRes, setMdRes,
placeholder, placeholder,
}: Props) { }: Props) {
const [prompt, setPrompt] = React.useState("");
const currentPromptRef = React.useRef(""); const currentPromptRef = React.useRef("");
const promptInputRef = React.useRef<HTMLTextAreaElement>(null);
return ( return (
<Paper className=""> <Paper className="">
@@ -64,6 +62,7 @@ export default function AIPromptBlock({
mdRes, mdRes,
setMdRes, setMdRes,
placeholder, placeholder,
currentPromptRef,
}} }}
/> />
</Stack> </Stack>
@@ -13,6 +13,8 @@ type Props = {
mdRes?: string; mdRes?: string;
setMdRes: React.Dispatch<React.SetStateAction<string>>; setMdRes: React.Dispatch<React.SetStateAction<string>>;
placeholder?: string; placeholder?: string;
/** Receives the last submitted prompt */
currentPromptRef?: React.MutableRefObject<string>;
}; };
export default function AIPromptBlockInputSection({ export default function AIPromptBlockInputSection({
@@ -23,9 +25,12 @@ export default function AIPromptBlockInputSection({
mdRes = "", mdRes = "",
setMdRes, setMdRes,
placeholder, placeholder,
currentPromptRef: externalCurrentPromptRef,
}: Props) { }: Props) {
const [prompt, setPrompt] = React.useState(""); const [prompt, setPrompt] = React.useState("");
const currentPromptRef = React.useRef(""); const internalCurrentPromptRef = React.useRef("");
const currentPromptRef =
externalCurrentPromptRef || internalCurrentPromptRef;
const promptInputRef = React.useRef<HTMLTextAreaElement>(null); const promptInputRef = React.useRef<HTMLTextAreaElement>(null);
return ( return (
@@ -44,6 +49,7 @@ export default function AIPromptBlockInputSection({
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key == "Enter" && !e.ctrlKey && !e.shiftKey) { if (e.key == "Enter" && !e.ctrlKey && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
if (!prompt.trim() || loading) return;
currentPromptRef.current = prompt; currentPromptRef.current = prompt;
setTimeout(() => { setTimeout(() => {
setPrompt(""); setPrompt("");
+4 -3
View File
@@ -53,9 +53,10 @@ export default function AIVoiceTranscription({
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
const history_last_response = history?.find( const history_last_response = history
(h) => h.role == "assistant", ?.slice()
); .reverse()
.find((h) => h.role == "assistant");
voice_transcription_fn({ voice_transcription_fn({
last_message: last_message:
last_response || last_response ||
+1 -3
View File
@@ -71,9 +71,7 @@ export default function Checkbox({
defaultChecked || externalChecked || false, defaultChecked || externalChecked || false,
); );
const finalTitle = title const finalTitle = title;
? title
: `Checkbox-${Math.round(Math.random() * 100000)}`;
React.useEffect(() => { React.useEffect(() => {
if (typeof externalChecked == "undefined") return; if (typeof externalChecked == "undefined") return;
+17 -6
View File
@@ -139,8 +139,8 @@ export default function FileUpload({
<input <input
type="file" type="file"
multiple={multiple} multiple={multiple}
className={twMerge("hidden", fileInputProps?.className)}
{...fileInputProps} {...fileInputProps}
className={twMerge("hidden", fileInputProps?.className)}
onChange={(e) => { onChange={(e) => {
if (multiple) { if (multiple) {
(async () => { (async () => {
@@ -154,11 +154,13 @@ export default function FileUpload({
const file = files[i]; const file = files[i];
const fileObj = await fileInputToBase64({ const fileObj = await fileInputToBase64({
inputFile: file!, inputFile: file!,
allowedRegex,
}); });
filesArr.push(fileObj); if (fileObj.fileBase64Full) filesArr.push(fileObj);
} }
externalSetFiles?.(filesArr); externalSetFiles?.(filesArr);
fileInputProps?.onChange?.(e);
})(); })();
} else { } else {
const inputFile = e.target.files?.[0]; const inputFile = e.target.files?.[0];
@@ -249,8 +251,11 @@ export default function FileUpload({
</Card> </Card>
) : fileUrl ? ( ) : fileUrl ? (
<Card <Card
className="w-full relative h-full items-center justify-center overflow-hidden"
{...previewImageWrapperProps} {...previewImageWrapperProps}
className={twMerge(
"w-full relative h-full items-center justify-center overflow-hidden",
previewImageWrapperProps?.className,
)}
> >
<Stack className="w-full"> <Stack className="w-full">
{disablePreview ? ( {disablePreview ? (
@@ -295,6 +300,8 @@ export default function FileUpload({
onChangeHandler?.(undefined); onChangeHandler?.(undefined);
changeHandler?.(undefined); changeHandler?.(undefined);
setFileUrl(undefined); setFileUrl(undefined);
externalSetFileURL?.(undefined);
onClear?.();
}} }}
title="Cancel File Button" title="Cancel File Button"
> >
@@ -303,6 +310,7 @@ export default function FileUpload({
</Card> </Card>
) : ( ) : (
<Card <Card
{...placeHolderWrapper}
className={twMerge( className={twMerge(
"w-full h-full cursor-pointer hover:bg-slate-100/50 dark:hover:bg-white/5", "w-full h-full cursor-pointer hover:bg-slate-100/50 dark:hover:bg-white/5",
"border-dashed border-2", "border-dashed border-2",
@@ -343,12 +351,15 @@ export default function FileUpload({
(res) => { (res) => {
setFile(res); setFile(res);
externalSetFile?.(res); externalSetFile?.(res);
onChangeHandler?.(res); onChangeHandler?.(
changeHandler?.(res); res,
inputRef,
fileInputUtils,
);
changeHandler?.(res, inputRef, fileInputUtils);
}, },
); );
}} }}
{...placeHolderWrapper}
> >
<Center <Center
className={twMerge( className={twMerge(
+12 -6
View File
@@ -104,17 +104,20 @@ export default function ImageUpload({
> >
<input <input
type="file" type="file"
className={twMerge("hidden", fileInputProps?.className)}
multiple={multiple} multiple={multiple}
accept="image/*" accept="image/*"
{...fileInputProps} {...fileInputProps}
className={twMerge("hidden", fileInputProps?.className)}
onChange={(e) => { onChange={(e) => {
setLoading?.(true); setLoading?.(true);
if (multiple) { if (multiple) {
(async () => { (async () => {
const files = e.target.files; const files = e.target.files;
if (!files?.[0]) return; if (!files?.[0]) {
setLoading?.(false);
return;
}
let imgArr: ImageInputToBase64FunctionReturn[] = []; let imgArr: ImageInputToBase64FunctionReturn[] = [];
@@ -147,8 +150,11 @@ export default function ImageUpload({
{src || imageObject?.imageBase64Full ? ( {src || imageObject?.imageBase64Full ? (
<Card <Card
className="w-full relative h-full items-center justify-center"
{...previewImageWrapperProps} {...previewImageWrapperProps}
className={twMerge(
"w-full relative h-full items-center justify-center",
previewImageWrapperProps?.className,
)}
> >
{label && ( {label && (
<label <label
@@ -183,7 +189,7 @@ export default function ImageUpload({
setImageObject?.(undefined); setImageObject?.(undefined);
externalSetImage?.(undefined); externalSetImage?.(undefined);
if (inputRef.current) { if (inputRef.current) {
inputRef.current.value == ""; inputRef.current.value = "";
} }
}} }}
title="Cancel Image Upload Button" title="Cancel Image Upload Button"
@@ -193,6 +199,7 @@ export default function ImageUpload({
</Card> </Card>
) : ( ) : (
<Card <Card
{...placeHolderWrapper}
className={twMerge( className={twMerge(
"w-full h-full cursor-pointer hover:bg-slate-100 dark:hover:bg-white/20", "w-full h-full cursor-pointer hover:bg-slate-100 dark:hover:bg-white/20",
placeHolderWrapper?.className, placeHolderWrapper?.className,
@@ -206,7 +213,6 @@ export default function ImageUpload({
inputRef.current?.click(); inputRef.current?.click();
placeHolderWrapper?.onClick?.(e); placeHolderWrapper?.onClick?.(e);
}} }}
{...placeHolderWrapper}
> >
<Center> <Center>
<Stack className="items-center gap-2"> <Stack className="items-center gap-2">
@@ -229,7 +235,7 @@ export default function ImageUpload({
}} }}
/> />
<Button <Button
title="Restore Image Button" title="Set Image URL Button"
size="smaller" size="smaller"
variant="outlined" variant="outlined"
color="gray" color="gray"
+7
View File
@@ -59,6 +59,7 @@ export default function NumberInputButtons({
function incrementDownPress() { function incrementDownPress() {
window.clearTimeout(pressTimeout); window.clearTimeout(pressTimeout);
window.clearInterval(pressInterval);
setButtonDown(true); setButtonDown(true);
pressTimeout = setTimeout(() => { pressTimeout = setTimeout(() => {
@@ -75,6 +76,8 @@ export default function NumberInputButtons({
} }
function decrementDownPress() { function decrementDownPress() {
window.clearTimeout(pressTimeout);
window.clearInterval(pressInterval);
setButtonDown(true); setButtonDown(true);
pressTimeout = setTimeout(() => { pressTimeout = setTimeout(() => {
@@ -143,7 +146,9 @@ export default function NumberInputButtons({
onMouseDown={decrementDownPress} onMouseDown={decrementDownPress}
onTouchStart={decrementDownPress} onTouchStart={decrementDownPress}
onMouseUp={decrementDownCancel} onMouseUp={decrementDownCancel}
onMouseLeave={decrementDownCancel}
onTouchEnd={decrementDownCancel} onTouchEnd={decrementDownCancel}
onTouchCancel={decrementDownCancel}
> >
<Minus size={20} /> <Minus size={20} />
</Row> </Row>
@@ -156,7 +161,9 @@ export default function NumberInputButtons({
onMouseDown={incrementDownPress} onMouseDown={incrementDownPress}
onTouchStart={incrementDownPress} onTouchStart={incrementDownPress}
onMouseUp={incrementDownCancel} onMouseUp={incrementDownCancel}
onMouseLeave={incrementDownCancel}
onTouchEnd={incrementDownCancel} onTouchEnd={incrementDownCancel}
onTouchCancel={incrementDownCancel}
> >
<Plus size={20} /> <Plus size={20} />
</Row> </Row>
+37 -28
View File
@@ -153,8 +153,10 @@ export default function Input<KeyType extends string>(
}, },
); );
const inputRef = componentRef || React.useRef<HTMLInputElement>(null); const internalInputRef = React.useRef<HTMLInputElement>(null);
const textAreaRef = componentRef || React.useRef<HTMLTextAreaElement>(null); const internalTextAreaRef = React.useRef<HTMLTextAreaElement>(null);
const inputRef = componentRef || internalInputRef;
const textAreaRef = componentRef || internalTextAreaRef;
const buttonDownRef = React.useRef(false); const buttonDownRef = React.useRef(false);
const [value, setValue] = React.useState( const [value, setValue] = React.useState(
@@ -344,7 +346,11 @@ export default function Input<KeyType extends string>(
return ( return (
<Stack <Stack
title={`${finalLabel}${props.required ? " (Required)" : ""}`} title={
finalLabel
? `${finalLabel}${props.required ? " (Required)" : ""}`
: undefined
}
{...wrapperWrapperProps} {...wrapperWrapperProps}
className={twMerge( className={twMerge(
"w-full gap-1.5 relative z-0 hover:z-100", "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" title="Copy value"
className="cursor-pointer opacity-50 hover:opacity-100" className="cursor-pointer opacity-50 hover:opacity-100"
onClick={() => { onClick={() => {
const text = const text = (
inputRef.current.value || istextarea ? textAreaRef : inputRef
textAreaRef.current.value; ).current?.value;
if (text) { if (text) {
navigator.clipboard navigator.clipboard
@@ -446,14 +452,18 @@ export default function Input<KeyType extends string>(
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
if (inputRef.current) { const element = istextarea
inputRef.current.value = ""; ? textAreaRef.current
} : inputRef.current;
if (textAreaRef.current) {
textAreaRef.current.value = ""; // Goes through React's onChange so controlled
// inputs and `onChange` listeners see the clear
if (element) {
setNativeInputValue(element, "");
} else {
setValue("");
} }
setValue("");
clearInputProps?.onClick?.(e); clearInputProps?.onClick?.(e);
}} }}
> >
@@ -465,7 +475,7 @@ export default function Input<KeyType extends string>(
<div <div
title={ title={
inputType == "password" inputType == "password"
? "View Psasword" ? "View Password"
: "Hide Password" : "Hide Password"
} }
className={twMerge("p-1 -my-2 -mx-1")} 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 PICKER_INPUT_TYPES = ["date", "datetime-local", "time", "month", "week"];
const updateInput = ({ function setNativeInputValue(
newValue, element: HTMLInputElement | HTMLTextAreaElement,
inputRef, newValue: string,
}: { ) {
newValue: string; const prototype =
inputRef: RefObject<HTMLInputElement & HTMLTextAreaElement>; element instanceof HTMLTextAreaElement
}) => { ? window.HTMLTextAreaElement.prototype
const input = inputRef.current; : window.HTMLInputElement.prototype;
if (!input) return;
// Get the native setter (React overrides the default one) // Get the native setter (React overrides the default one)
const nativeInputValueSetter = Object.getOwnPropertyDescriptor( const nativeValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, prototype,
"value", "value",
)?.set; )?.set;
// Call it directly on the input // Call it directly on the element
nativeInputValueSetter?.call(input, newValue); nativeValueSetter?.call(element, newValue);
// Dispatch the event - React listens for 'input', not 'change' // 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( const commit = React.useCallback(
(option: TWUISelectOptionObject<KeyType, T>) => { (option: TWUISelectOptionObject<KeyType, T>) => {
if (multiple) { if (multiple) {
setSelected((prev) => { const exists = selected.some((s) => s.value === option.value);
const exists = prev.some((s) => s.value === option.value); const next = exists
const next = exists ? selected.filter((s) => s.value !== option.value)
? prev.filter((s) => s.value !== option.value) : [...selected, option];
: [...prev, option]; setSelected(next);
notify(next); notify(next);
return next;
});
setQuery(""); setQuery("");
setOpen(true); setOpen(true);
setActiveIndex(0); setActiveIndex(0);
@@ -174,20 +172,18 @@ export default function SearchSelect<
notify(next); notify(next);
} }
}, },
[multiple, notify], [multiple, notify, selected],
); );
const removeOne = React.useCallback( const removeOne = React.useCallback(
(value: KeyType) => { (value: KeyType) => {
setSelected((prev) => { const next = selected.filter((s) => s.value !== value);
const next = prev.filter((s) => s.value !== value); setSelected(next);
notify(next); notify(next);
return next;
});
if (!multiple) setQuery(""); if (!multiple) setQuery("");
inputRef.current?.focus(); inputRef.current?.focus();
}, },
[multiple, notify], [multiple, notify, selected],
); );
const clear = React.useCallback(() => { const clear = React.useCallback(() => {
@@ -204,6 +200,11 @@ export default function SearchSelect<
React.useEffect(() => { React.useEffect(() => {
if (initRef.current || !initialSelected.length) return; if (initRef.current || !initialSelected.length) return;
initRef.current = true; 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); notify(initialSelected);
}, [initialSelected, notify]); }, [initialSelected, notify]);
+31 -10
View File
@@ -102,15 +102,35 @@ export default function Select<
isValid: true, 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>>( const [value, setValue] = React.useState<TWUISelectValueObject<KeyType, T>>(
{ {
value: options[0]?.value!, value: initialOption?.value!,
data: options[0]?.data, 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(() => { React.useEffect(() => {
setTimeout(() => { setTimeout(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -135,7 +155,7 @@ export default function Select<
? twuiSlugify(props.name) ? twuiSlugify(props.name)
: props.title : props.title
? twuiSlugify(props.title) ? twuiSlugify(props.title)
: `select-${Math.round(Math.random() * 1000000)}`; : `select-${generatedId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
return ( return (
<Stack <Stack
@@ -181,15 +201,16 @@ export default function Select<
props.className, props.className,
)} )}
ref={selectRef} ref={selectRef}
value={ value={props.value ?? selectedValue}
options.flat().find((opt) => opt.default)?.value ||
undefined
}
onChange={(e) => { onChange={(e) => {
const targetValue = options.find( const targetValue = flatOptions.find(
(opt) => opt.value == e.target.value, (opt) => opt.value == e.target.value,
); );
if (selectedValue !== undefined) {
setSelectedValue(e.target.value);
}
if (targetValue) { if (targetValue) {
setValue(targetValue); setValue(targetValue);
} }
@@ -206,7 +227,7 @@ export default function Select<
}); });
}} }}
> >
{options.flat().map((option, index) => { {flatOptions.map((option, index) => {
const optionTitle = const optionTitle =
option.title || twuiSlugToNormalText(option.value); option.title || twuiSlugToNormalText(option.value);
+1 -1
View File
@@ -31,7 +31,7 @@ export default function useCustomEventDispatch<
}); });
window.dispatchEvent(event); window.dispatchEvent(event);
}, []); }, [name]);
return { dispatchCustomEvent }; return { dispatchCustomEvent };
} }
+1 -1
View File
@@ -33,7 +33,7 @@ export default function useCustomEventListener<
return function () { return function () {
window.removeEventListener(name, dataEventListenerCallback); window.removeEventListener(name, dataEventListenerCallback);
}; };
}, []); }, [name]);
return { data, message }; return { data, message };
} }
+9 -6
View File
@@ -53,12 +53,10 @@ export default function useIntersectionObserver({
: null; : null;
if (!element && !className && refresh < 5) { if (!element && !className && refresh < 5) {
requestAnimationFrame(() => { const retryTimeout = setTimeout(() => {
setTimeout(() => { setRefresh(refresh + 1);
setRefresh(refresh + 1); }, 2000);
}, 2000); return () => window.clearTimeout(retryTimeout);
});
return;
} }
const observer = new IntersectionObserver(observerCallback, { const observer = new IntersectionObserver(observerCallback, {
@@ -73,6 +71,11 @@ export default function useIntersectionObserver({
} else if (element) { } else if (element) {
observer.observe(element); observer.observe(element);
} }
return () => {
observer.disconnect();
window.clearTimeout(timeoutRef.current);
};
}, [refresh]); }, [refresh]);
return { isIntersecting }; return { isIntersecting };
+5 -7
View File
@@ -4,24 +4,22 @@ type Params = {
timeout?: number; timeout?: number;
}; };
let timeout: any;
export default function useReady(params?: Params) { export default function useReady(params?: Params) {
const [ready, setReady] = React.useState(false); const [ready, setReady] = React.useState(false);
const timeoutRef = React.useRef<any>(null);
const finalTimeout = params?.timeout || 300; const finalTimeout = params?.timeout || 300;
React.useEffect(() => { React.useEffect(() => {
requestAnimationFrame(() => { const frame = requestAnimationFrame(() => {
window.clearTimeout(timeout); timeoutRef.current = setTimeout(() => {
timeout = setTimeout(() => {
setReady(true); setReady(true);
}, finalTimeout); }, finalTimeout);
}); });
return function () { 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 * # Connect to Websocket
*/ */
@@ -91,6 +104,7 @@ export default function useWebSocket<
if (!wsURL) return; if (!wsURL) return;
const ws = new WebSocket(wsURL); const ws = new WebSocket(wsURL);
socketRef.current = ws;
ws.onerror = () => { ws.onerror = () => {
console.log(`Websocket ERROR:`); console.log(`Websocket ERROR:`);
@@ -110,12 +124,13 @@ export default function useWebSocket<
}, keepAliveDurationRef.current); }, keepAliveDurationRef.current);
tries.current = 0; tries.current = 0;
socketRef.current = ws;
setSocket(ws); setSocket(ws);
console.log(`Websocket connected to ${wsURL}`); console.log(`Websocket connected to ${wsURL}`);
}; };
ws.onclose = (ev) => { ws.onclose = (ev) => {
if (socketRef.current !== ws) return;
console.log("Websocket closed!", { console.log("Websocket closed!", {
code: ev.code, code: ev.code,
reason: ev.reason, reason: ev.reason,
@@ -150,7 +165,8 @@ export default function useWebSocket<
window.clearTimeout(reconnectTimeout.current); window.clearTimeout(reconnectTimeout.current);
window.clearInterval(keepAliveInterval.current); window.clearInterval(keepAliveInterval.current);
window.clearInterval(refreshInterval.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(() => { refreshInterval.current = window.setInterval(() => {
console.log("Refreshing WebSocket connection..."); console.log("Refreshing WebSocket connection...");
window.clearTimeout(reconnectTimeout.current); window.clearTimeout(reconnectTimeout.current);
socketRef.current?.close(); closeSocket(socketRef.current);
socketRef.current = undefined;
setSocket(undefined);
tries.current = 0; tries.current = 0;
connect(); connect();
}, refreshConnection); }, refreshConnection);
@@ -187,16 +205,17 @@ export default function useWebSocket<
* Received Message Queue Handler * Received Message Queue Handler
*/ */
const handleReceivedMessageQueue = React.useCallback(() => { const handleReceivedMessageQueue = React.useCallback(() => {
try { while (messageQueueRef.current.length) {
const msg = messageQueueRef.current.shift(); const msg = messageQueueRef.current.shift();
if (!msg) continue;
if (!msg) return;
const jsonData = JSON.parse(msg);
dispatchCustomEvent("wsMessageEvent", msg); dispatchCustomEvent("wsMessageEvent", msg);
dispatchCustomEvent("wsDataEvent", jsonData);
} catch (error) { try {
console.log("Unable to parse string. Returning string."); 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 type { TWUI_LINK_LIST_LINK_OBJECT } from "../elements/LinkList";
import Link from "./Link"; import Link from "./Link";
import Row from "./Row"; import Row from "./Row";
import { twMerge } from "tailwind-merge";
type Props = ComponentProps<typeof Link> & { type Props = ComponentProps<typeof Link> & {
link: TWUI_LINK_LIST_LINK_OBJECT; link: TWUI_LINK_LIST_LINK_OBJECT;
@@ -15,7 +16,16 @@ type Props = ComponentProps<typeof Link> & {
*/ */
export default function ArrowedLink({ link, icon, ...props }: Props) { export default function ArrowedLink({ link, icon, ...props }: Props) {
return ( 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> <Row>
<span>{link.title}</span> <span>{link.title}</span>
{icon || <ArrowUpRight size={17} />} {icon || <ArrowUpRight size={17} />}
+2
View File
@@ -277,6 +277,8 @@ export default function Button({
const buttonComponent = ( const buttonComponent = (
<button <button
{...props} {...props}
disabled={props.disabled || loading}
aria-busy={loading || undefined}
className={twMerge( className={twMerge(
"bg-primary text-white font-medium px-4 py-2 rounded-default", "bg-primary text-white font-medium px-4 py-2 rounded-default",
"flex items-center justify-center relative transition-all cursor-pointer", "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 type { TWUI_LINK_LIST_LINK_OBJECT } from "../elements/LinkList";
import Link from "./Link"; import Link from "./Link";
import Row from "./Row"; import Row from "./Row";
import { twMerge } from "tailwind-merge";
type Props = ComponentProps<typeof Link> & { type Props = ComponentProps<typeof Link> & {
link: TWUI_LINK_LIST_LINK_OBJECT; link: TWUI_LINK_LIST_LINK_OBJECT;
@@ -11,7 +12,7 @@ type Props = ComponentProps<typeof Link> & {
/** /**
* # Link With an Icon * # Link With an Icon
* @className twui-arrowed-link * @className twui-icon-link
*/ */
export default function IconLink({ export default function IconLink({
link, link,
@@ -23,6 +24,7 @@ export default function IconLink({
<Link <Link
href={link.url} href={link.url}
{...props} {...props}
className={twMerge("twui-icon-link", props.className)}
onClick={(e) => { onClick={(e) => {
link.onClick?.(e); link.onClick?.(e);
props.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(() => { useEffect(() => {
if (checkImageFn) { if (checkImageFn) {
(async () => { (async () => {
@@ -122,10 +139,7 @@ export default function Img({
interpolatedProps.className, interpolatedProps.className,
)} )}
src={props.srcDark} src={props.srcDark}
onError={(e) => { onError={handleError}
setImageError(true);
props.onError?.(e);
}}
/> />
<img <img
loading="lazy" loading="lazy"
@@ -135,10 +149,7 @@ export default function Img({
interpolatedProps.className, interpolatedProps.className,
)} )}
src={props.srcLight} src={props.srcLight}
onError={(e) => { onError={handleError}
setImageError(true);
props.onError?.(e);
}}
/> />
</React.Fragment> </React.Fragment>
); );
@@ -149,10 +160,7 @@ export default function Img({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
{...interpolatedProps} {...interpolatedProps}
onError={(e) => { onError={handleError}
setImageError(true);
props.onError?.(e);
}}
/> />
); );
} }
+1 -1
View File
@@ -3,7 +3,7 @@ import { twMerge } from "tailwind-merge";
/** /**
* # Main Wrapper * # Main Wrapper
* @className twui-h1 * @className twui-main
*/ */
export default function Main({ export default function Main({
...props ...props
+12 -1
View File
@@ -25,10 +25,21 @@ export default function Span({
size == "large" && "text-lg", size == "large" && "text-lg",
size == "larger" && "text-xl", size == "larger" && "text-xl",
variant == "faded" && "opacity-50", variant == "faded" && "opacity-50",
truncate ? `` : ``,
"twui-span", "twui-span",
props.className, props.className,
)} )}
style={
truncate
? {
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: truncate.lines || 1,
overflow: "hidden",
maxWidth: truncate.width,
...props.style,
}
: props.style
}
> >
{props.children} {props.children}
</span> </span>
+13 -5
View File
@@ -23,14 +23,22 @@ export default function Stack({ gap, componentRef, ...props }: Props) {
className={twMerge( className={twMerge(
"flex flex-col items-start gap-4", "flex flex-col items-start gap-4",
props.center && "items-center", props.center && "items-center",
gap
? typeof gap == "string"
? `gap-[${gap}]`
: `gap-${gap}`
: "",
"twui-stack", "twui-stack",
props.className, 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} ref={componentRef}
> >
{props.children} {props.children}
+3 -4
View File
@@ -65,9 +65,10 @@ export default function MarkdownEditor({
{sideBySide ? ( {sideBySide ? (
<Row <Row
className={twMerge( 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", "overflow-auto",
)} )}
style={{ maxHeight }}
> >
<MarkdownEditorComponent <MarkdownEditorComponent
setValue={setValue} setValue={setValue}
@@ -81,9 +82,7 @@ export default function MarkdownEditor({
/> />
</Row> </Row>
) : ( ) : (
<Stack <Stack className="w-full h-full" style={{ maxHeight }}>
className={twMerge(`w-full max-h-[${maxHeight}] h-full`)}
>
{preview ? ( {preview ? (
<MarkdownEditorPreviewComponent <MarkdownEditorPreviewComponent
value={value} value={value}
+1 -2
View File
@@ -21,8 +21,7 @@ export default function MarkdownEditorComponent({
setValue(newValue); setValue(newValue);
}} }}
wrapperProps={{ wrapperProps={{
style: { height: maxHeight }, style: { height: maxHeight, maxHeight },
className: `max-h-[${maxHeight}]`,
}} }}
placeholder="## Write Some markdown ..." placeholder="## Write Some markdown ..."
fontSize="14px" fontSize="14px"
@@ -38,8 +38,9 @@ export default function MarkdownEditorPreviewComponent({
try { try {
// const { data, content } = matter(value); // const { data, content } = matter(value);
// Strip leading front matter only (not `---` rules in the body)
const parsedValue = value const parsedValue = value
.replace(/---([^(---)]+)---/, "") .replace(/^\s*---\r?\n[\s\S]*?\r?\n---[ \t]*(\r?\n|$)/, "")
.trim(); .trim();
serialize(parsedValue, { serialize(parsedValue, {
@@ -62,10 +63,15 @@ export default function MarkdownEditorPreviewComponent({
<Border <Border
{...wrapperProps} {...wrapperProps}
className={twMerge( className={twMerge(
`w-full max-h-[${maxHeight}] h-[${maxHeight}] block px-6 pb-10`, "w-full block px-6 pb-10",
"overflow-auto", "overflow-auto",
wrapperProps?.className, wrapperProps?.className,
)} )}
style={{
maxHeight,
height: maxHeight,
...wrapperProps?.style,
}}
> >
{mdxSource ? ( {mdxSource ? (
<MDXRemote <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" });
});
});
+147 -123
View File
@@ -1,123 +1,147 @@
import _ from "lodash"; import twuiSerializeQuery from "../serialize-query";
import twuiSerializeQuery from "../serialize-query";
export const FetchAPIMethods = [
export const FetchAPIMethods = [ "POST",
"POST", "GET",
"GET", "DELETE",
"DELETE", "PUT",
"PUT", "PATCH",
"PATCH", "post",
"post", "get",
"get", "delete",
"delete", "put",
"put", "patch",
"patch", ] as const;
] as const;
type FetchApiOptions<T extends { [k: string]: any } = { [k: string]: any }> = {
type FetchApiOptions<T extends { [k: string]: any } = { [k: string]: any }> = { method: (typeof FetchAPIMethods)[number];
method: (typeof FetchAPIMethods)[number]; body?: T | string | BodyInit;
body?: T | string; headers?: FetchHeader;
headers?: FetchHeader; query?: T;
query?: T; csrfValue?: string;
csrfValue?: string; csrfKey?: string;
csrfKey?: string; fetchOptions?: RequestInit;
fetchOptions?: RequestInit; };
};
type FetchHeader = HeadersInit & {
type FetchHeader = HeadersInit & { [key: string]: string | null;
[key: string]: string | null; };
};
export type FetchApiReturn = {
export type FetchApiReturn = { success: boolean;
success: boolean; payload: any;
payload: any; msg?: string;
msg?: string; [key: string]: any;
[key: string]: any; };
};
function isNativeBody(body: unknown): body is BodyInit {
/** return (
* # Fetch API (typeof FormData !== "undefined" && body instanceof FormData) ||
*/ (typeof Blob !== "undefined" && body instanceof Blob) ||
export default async function fetchApi< (typeof URLSearchParams !== "undefined" &&
T extends { [k: string]: any } = { [k: string]: any }, body instanceof URLSearchParams) ||
R extends any = any, body instanceof ArrayBuffer ||
>(url: string, options?: FetchApiOptions<T>): Promise<R> { ArrayBuffer.isView(body)
let data; );
}
let finalHeaders = {
"Content-Type": "application/json", /**
} as FetchHeader; * # Fetch API
*/
if (options?.csrfKey && options.csrfValue) { export default async function fetchApi<
finalHeaders[options.csrfKey] = options.csrfValue; T extends { [k: string]: any } = { [k: string]: any },
} R extends any = any,
>(url: string, options?: FetchApiOptions<T>): Promise<R> {
let finalURL = url; let data;
if (options?.query) { let finalHeaders = {
finalURL += twuiSerializeQuery(options.query); "Content-Type": "application/json",
} } as FetchHeader;
if (typeof options === "string") { if (options?.csrfKey && options.csrfValue) {
try { finalHeaders[options.csrfKey] = options.csrfValue;
let fetchData; }
switch (options) { if (options?.headers) {
case "post": for (const [key, value] of Object.entries(options.headers)) {
fetchData = await fetch(finalURL, { if (value === null || value === undefined) continue;
method: options, // Header names are case-insensitive; caller values win
headers: finalHeaders, for (const existingKey of Object.keys(finalHeaders)) {
} as RequestInit); if (existingKey.toLowerCase() === key.toLowerCase()) {
data = await fetchData.json(); delete finalHeaders[existingKey];
break; }
}
default: finalHeaders[key] = value;
fetchData = await fetch(finalURL); }
data = await fetchData.json(); }
break;
} let finalURL = url;
} catch (error: any) {
console.log("FetchAPI error #1:", error.message); if (options?.query) {
data = { success: false, msg: error.message }; finalURL += twuiSerializeQuery(options.query);
} }
} else if (typeof options === "object") {
try { if (typeof options === "string") {
let fetchData; try {
let fetchData;
if (options.body && typeof options.body === "object") {
let oldOptionsBody = _.cloneDeep(options.body); switch (options) {
options.body = JSON.stringify(oldOptionsBody); case "post":
} fetchData = await fetch(finalURL, {
method: options,
if (options.headers) { headers: finalHeaders,
options.headers = _.merge(options.headers, finalHeaders); } as RequestInit);
data = await fetchData.json();
const finalOptions: any = { ...options }; break;
fetchData = await fetch(finalURL, finalOptions); default:
} else { fetchData = await fetch(finalURL);
const finalOptions = { data = await fetchData.json();
...options, break;
headers: finalHeaders, }
} as RequestInit; } catch (error: any) {
console.log("FetchAPI error #1:", error.message);
fetchData = await fetch(finalURL, finalOptions); data = { success: false, msg: error.message };
} }
} else if (typeof options === "object") {
data = await fetchData.json(); try {
} catch (error: any) { let body: BodyInit | undefined;
console.log("FetchAPI error #2:", error.message);
data = { success: false, msg: error.message }; if (isNativeBody(options.body)) {
} body = options.body;
} else { // Let the browser set the multipart boundary etc.
try { if (finalHeaders["Content-Type"] === "application/json") {
let fetchData = await fetch(finalURL); delete finalHeaders["Content-Type"];
data = await fetchData.json(); }
} catch (error: any) { } else if (options.body && typeof options.body === "object") {
console.log("FetchAPI error #3:", error.message); body = JSON.stringify(options.body);
data = { success: false, msg: error.message }; } else {
} body = options.body as BodyInit | undefined;
} }
return data; 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);
data = { success: false, msg: error.message };
}
} else {
try {
let fetchData = await fetch(finalURL);
data = await fetchData.json();
} catch (error: any) {
console.log("FetchAPI error #3:", error.message);
data = { success: false, msg: error.message };
}
}
return data;
}
+3 -3
View File
@@ -23,7 +23,7 @@ export default async function fileInputToBase64({
return { fileName: inputFile.name }; return { fileName: inputFile.name };
} }
let fileName = inputFile.name?.replace(/\..*/, ""); let fileName = inputFile.name?.replace(/\.[^.]*$/, "");
const file = inputFile; const file = inputFile;
try { try {
@@ -34,8 +34,8 @@ export default async function fileInputToBase64({
reader.onload = function () { reader.onload = function () {
resolve(reader.result?.toString()); resolve(reader.result?.toString());
}; };
reader.onerror = function (/** @type {*} */ error: any) { reader.onerror = function () {
console.log("File Input to Base64 Error: ", error.message); reject(reader.error);
}; };
} }
); );
+1 -1
View File
@@ -25,7 +25,7 @@ export default async function imageInputToBase64({
throw new Error("No Files found"); throw new Error("No Files found");
} }
let imageName = finalFile.name.replace(/\..*/, ""); let imageName = finalFile.name.replace(/\.[^.]*$/, "");
const MIME_TYPE = mimeType ? mimeType : finalFile.type; const MIME_TYPE = mimeType ? mimeType : finalFile.type;
const QUALITY = 0.95; 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 }); const result = twuiSerializeQuery({ active: true as any });
expect(result).toContain("active=true"); 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[] = []; const queryArr: string[] = [];
keys.forEach((key) => { keys.forEach((key) => {
if (!key || !query[key]) return;
const value = query[key]; const value = query[key];
if (!key || (!value && value !== 0)) return;
if (typeof value === "object") { if (typeof value === "object") {
const jsonStr = TWUIEJSON.stringify(value); const jsonStr = TWUIEJSON.stringify(value);
+4
View File
@@ -69,4 +69,8 @@ describe("twuiSlugify", () => {
test("handles number-only input", () => { test("handles number-only input", () => {
expect(twuiSlugify("123")).toBe("123"); 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(/ /g, finalSlugDivider)
.replace(/[^a-z0-9]/g, finalSlugDivider) .replace(/[^a-z0-9]/g, finalSlugDivider)
.replace(/-{2,}|_{2,}/g, finalSlugDivider) .replace(/-{2,}|_{2,}/g, finalSlugDivider)
.replace(/^-/, ""); .replace(/^[-_]/, "");
if (allowTrailingDash) { if (allowTrailingDash) {
return finalStr; return finalStr;
} }
return finalStr.replace(/-$/, ""); return finalStr.replace(/[-_]$/, "");
} catch (error: any) { } catch (error: any) {
return ""; return "";
} }