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:
@@ -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%)";
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 || [])]);
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function useCustomEventDispatch<
|
||||
});
|
||||
|
||||
window.dispatchEvent(event);
|
||||
}, []);
|
||||
}, [name]);
|
||||
|
||||
return { dispatchCustomEvent };
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function useCustomEventListener<
|
||||
return function () {
|
||||
window.removeEventListener(name, dataEventListenerCallback);
|
||||
};
|
||||
}, []);
|
||||
}, [name]);
|
||||
|
||||
return { data, message };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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`
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -3,7 +3,7 @@ import { twMerge } from "tailwind-merge";
|
||||
|
||||
/**
|
||||
* # Main Wrapper
|
||||
* @className twui-h1
|
||||
* @className twui-main
|
||||
*/
|
||||
export default function Main({
|
||||
...props
|
||||
|
||||
+12
-1
@@ -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
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}/`);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user