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>`
|
- **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%)";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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 || [])]);
|
||||||
|
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -46,7 +46,11 @@ export default function Breadcrumbs({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const [links, setLinks] = React.useState<LinkObject[] | null>(
|
const [links, setLinks] = React.useState<LinkObject[] | null>(
|
||||||
pageUrl
|
pageUrl
|
||||||
? twuiBreadcrumbsGenerateLinksFromUrl({ url: pageUrl, skipHome })
|
? twuiBreadcrumbsGenerateLinksFromUrl({
|
||||||
|
url: pageUrl,
|
||||||
|
excludeRegexMatch,
|
||||||
|
skipHome,
|
||||||
|
})
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -155,8 +159,7 @@ export default function Breadcrumbs({
|
|||||||
isTarget && currentLinkProps?.className,
|
isTarget && currentLinkProps?.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{currentLinkProps?.title ||
|
{linkObject.title}
|
||||||
linkObject.title}
|
|
||||||
</Link>
|
</Link>
|
||||||
{divider || (
|
{divider || (
|
||||||
<Divider
|
<Divider
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -24,15 +24,23 @@ export default function RemoteCodeBlock({ content, refresh }: Props) {
|
|||||||
const { components } = useMDXComponents();
|
const { components } = useMDXComponents();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
serialize(content, {
|
serialize(content, {
|
||||||
mdxOptions: {
|
mdxOptions: {
|
||||||
remarkPlugins: [remarkGfm],
|
remarkPlugins: [remarkGfm],
|
||||||
rehypePlugins: [rehypePrismPlus],
|
rehypePlugins: [rehypePrismPlus],
|
||||||
},
|
},
|
||||||
}).then((mdxSrc) => {
|
})
|
||||||
setMdxSource(mdxSrc);
|
.then((mdxSrc) => {
|
||||||
});
|
if (!cancelled) setMdxSource(mdxSrc);
|
||||||
}, [refresh]);
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [content, refresh]);
|
||||||
|
|
||||||
if (!mdxSource) {
|
if (!mdxSource) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|||||||
@@ -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 ||
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default function useCustomEventDispatch<
|
|||||||
});
|
});
|
||||||
|
|
||||||
window.dispatchEvent(event);
|
window.dispatchEvent(event);
|
||||||
}, []);
|
}, [name]);
|
||||||
|
|
||||||
return { dispatchCustomEvent };
|
return { dispatchCustomEvent };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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
@@ -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`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -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} />}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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";
|
import twuiSerializeQuery from "../serialize-query";
|
||||||
|
|
||||||
export const FetchAPIMethods = [
|
export const FetchAPIMethods = [
|
||||||
@@ -16,7 +15,7 @@ export const FetchAPIMethods = [
|
|||||||
|
|
||||||
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;
|
body?: T | string | BodyInit;
|
||||||
headers?: FetchHeader;
|
headers?: FetchHeader;
|
||||||
query?: T;
|
query?: T;
|
||||||
csrfValue?: string;
|
csrfValue?: string;
|
||||||
@@ -35,6 +34,17 @@ export type FetchApiReturn = {
|
|||||||
[key: string]: any;
|
[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
|
* # Fetch API
|
||||||
*/
|
*/
|
||||||
@@ -52,6 +62,19 @@ export default async function fetchApi<
|
|||||||
finalHeaders[options.csrfKey] = options.csrfValue;
|
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;
|
let finalURL = url;
|
||||||
|
|
||||||
if (options?.query) {
|
if (options?.query) {
|
||||||
@@ -82,28 +105,29 @@ export default async function fetchApi<
|
|||||||
}
|
}
|
||||||
} else if (typeof options === "object") {
|
} else if (typeof options === "object") {
|
||||||
try {
|
try {
|
||||||
let fetchData;
|
let body: BodyInit | undefined;
|
||||||
|
|
||||||
if (options.body && typeof options.body === "object") {
|
if (isNativeBody(options.body)) {
|
||||||
let oldOptionsBody = _.cloneDeep(options.body);
|
body = options.body;
|
||||||
options.body = JSON.stringify(oldOptionsBody);
|
// Let the browser set the multipart boundary etc.
|
||||||
}
|
if (finalHeaders["Content-Type"] === "application/json") {
|
||||||
|
delete finalHeaders["Content-Type"];
|
||||||
if (options.headers) {
|
}
|
||||||
options.headers = _.merge(options.headers, finalHeaders);
|
} else if (options.body && typeof options.body === "object") {
|
||||||
|
body = JSON.stringify(options.body);
|
||||||
const finalOptions: any = { ...options };
|
|
||||||
|
|
||||||
fetchData = await fetch(finalURL, finalOptions);
|
|
||||||
} else {
|
} else {
|
||||||
const finalOptions = {
|
body = options.body as BodyInit | undefined;
|
||||||
...options,
|
|
||||||
headers: finalHeaders,
|
|
||||||
} as RequestInit;
|
|
||||||
|
|
||||||
fetchData = await fetch(finalURL, finalOptions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const finalOptions: RequestInit = {
|
||||||
|
...options.fetchOptions,
|
||||||
|
method: options.method?.toUpperCase(),
|
||||||
|
headers: finalHeaders as HeadersInit,
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchData = await fetch(finalURL, finalOptions);
|
||||||
|
|
||||||
data = await fetchData.json();
|
data = await fetchData.json();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log("FetchAPI error #2:", error.message);
|
console.log("FetchAPI error #2:", error.message);
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 "";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user