This commit is contained in:
Benjamin Toby 2026-03-04 17:35:14 +01:00
parent f73b56cdc4
commit db26e26495
113 changed files with 12433 additions and 169 deletions

0
bun.lockb Executable file → Normal file
View File

View File

@ -0,0 +1,75 @@
import _ from "lodash";
import React from "react";
import { TWUIPopoverStyles } from "../../elements/Modal";
import twuiNumberfy from "../../utils/numberfy";
type Params = {
targetElRef: React.RefObject<HTMLElement | null>;
position: (typeof TWUIPopoverStyles)[number];
};
export default function twuiGrabPopoverStyles({
position,
targetElRef,
}: Params): React.CSSProperties {
if (!targetElRef.current) return {};
const rect = targetElRef.current.getBoundingClientRect();
const targetElCurrStyles = window.getComputedStyle(targetElRef.current);
const targetElRightPadding = twuiNumberfy(targetElCurrStyles.paddingRight);
let popoverStyle: React.CSSProperties = {
position: "absolute",
zIndex: 100,
};
const defaultBottomStyle: React.CSSProperties = {
top: rect.bottom + window.scrollY + 8,
left: rect.left + window.scrollX + rect.width / 2,
transform: "translateX(-50%)",
};
const defaultTopStyleStyle: React.CSSProperties = {
bottom: window.innerHeight - (rect.top + window.scrollY) + 8,
left: rect.left + window.scrollX + rect.width / 2,
transform: "translateX(-50%)",
};
if (position === "bottom") {
popoverStyle = _.merge(popoverStyle, defaultBottomStyle);
} else if (position === "bottom-left") {
popoverStyle = _.merge(
popoverStyle,
_.omit(defaultBottomStyle, ["transform"]),
{
left: rect.left,
} as React.CSSProperties
);
} else if (position === "bottom-right") {
popoverStyle = _.merge(
popoverStyle,
_.omit(defaultBottomStyle, ["left", "transform"]),
{
right:
window.innerWidth -
(rect.left + window.scrollX) -
rect.width -
targetElRightPadding,
} as React.CSSProperties
);
} else if (position === "top") {
popoverStyle = _.merge(popoverStyle, defaultTopStyleStyle);
} else if (position === "right") {
popoverStyle.top = rect.top + window.scrollY + rect.height / 2;
popoverStyle.left = rect.right + window.scrollX + 8;
popoverStyle.transform = "translateY(-50%)";
} else if (position === "left") {
popoverStyle.top = rect.top + window.scrollY + rect.height / 2;
popoverStyle.right =
window.innerWidth - (rect.left + window.scrollX) + 8;
popoverStyle.transform = "translateY(-50%)";
}
return popoverStyle;
}

View File

@ -0,0 +1,65 @@
import React from "react";
import { twMerge } from "tailwind-merge";
import ReactDOM from "react-dom";
import Button from "../layout/Button";
import { X } from "lucide-react";
import { TWUI_MODAL_PROPS } from "../elements/Modal";
import Paper from "../elements/Paper";
import _ from "lodash";
type Props = TWUI_MODAL_PROPS & {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
/**
* # Modal Main Component
*/
export default function ModalComponent({ open, setOpen, ...props }: Props) {
if (!open) return null;
return ReactDOM.createPortal(
<div
className={twMerge(
"fixed z-[200] top-0 left-0 w-screen h-screen",
"flex flex-col items-center justify-center p-4",
"twui-modal-root"
)}
role="dialog"
aria-modal="true"
>
<div
className={twMerge(
"absolute top-0 left-0 bg-dark/80 z-0",
"w-screen h-screen"
)}
onClick={(e) => {
setOpen(false);
}}
></div>
<Paper
{..._.omit(props, ["targetWrapperProps"])}
className={twMerge(
"z-10 max-w-modal bg-background-light dark:bg-background-dark",
"w-full relative max-h-[95vh] overflow-y-auto",
"twui-modal-content",
props.className
)}
>
{props.children}
<Button
className="absolute top-0 right-0 p-2"
variant="ghost"
color="gray"
onClick={() => {
setOpen(false);
}}
title="Close Modal Button"
>
<X size={30} />
</Button>
</Paper>
</div>,
document.getElementById("twui-modal-root") as HTMLElement
);
}

View File

@ -0,0 +1,115 @@
import React from "react";
import { twMerge } from "tailwind-merge";
import ReactDOM from "react-dom";
import { TWUI_MODAL_PROPS } from "../elements/Modal";
import Paper from "../elements/Paper";
import _ from "lodash";
import twuiGrabPopoverStyles from "../(functions)/popver/grab-popover-styles";
type Props = TWUI_MODAL_PROPS & {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
targetElRef?: React.RefObject<HTMLElement | null>;
popoverTargetActiveRef: React.MutableRefObject<boolean>;
popoverContentActiveRef: React.MutableRefObject<boolean>;
};
/**
* # Modal Main Component
*/
export default function PopoverComponent({
open,
setOpen,
targetElRef,
position = "bottom",
trigger = "hover",
debounce,
popoverTargetActiveRef,
popoverContentActiveRef,
popoverReferenceRef,
isPopover,
...props
}: Props) {
if (!open) return null;
const [style, setStyle] = React.useState({});
React.useEffect(() => {
if (open && targetElRef?.current) {
const popoverStyle = twuiGrabPopoverStyles({
position,
targetElRef,
});
setStyle(popoverStyle);
}
}, [open, targetElRef, position]);
let closeTimeout: any;
const popoverEnterFn = React.useCallback(() => {
popoverContentActiveRef.current = true;
popoverTargetActiveRef.current = false;
setOpen(true);
}, []);
const popoverLeaveFn = React.useCallback(() => {
window.clearTimeout(closeTimeout);
closeTimeout = setTimeout(() => {
if (popoverTargetActiveRef.current) {
popoverTargetActiveRef.current = false;
return;
}
setOpen(false);
}, debounce);
}, []);
if (!open) return null;
return ReactDOM.createPortal(
<Paper
{...props}
className={twMerge(
"max-w-[300px] z-[250]",
"twui-popover-content",
props.className
)}
style={{ ...style, ...props.style }}
onMouseEnter={
trigger === "hover" ? popoverEnterFn : props.onMouseEnter
}
onMouseLeave={
trigger === "hover" ? popoverLeaveFn : props.onMouseLeave
}
role="dialog"
aria-modal="true"
>
{/* <div
className="absolute w-0 h-0 border-8 border-transparent bg-white"
style={{
...(position === "bottom" && {
top: "-16px",
left: "50%",
transform: "translateX(-50%)",
}),
...(position === "top" && {
bottom: "-16px",
left: "50%",
transform: "translateX(-50%)",
}),
...(position === "right" && {
top: "50%",
left: "-16px",
transform: "translateY(-50%)",
}),
...(position === "left" && {
top: "50%",
right: "-16px",
transform: "translateY(-50%)",
}),
}}
/> */}
{props.children}
</Paper>,
document.getElementById("twui-popover-root") as HTMLElement
);
}

17
components/Readme.md Normal file
View File

@ -0,0 +1,17 @@
# Tailwind CSS UI
A modular skeletal framework for tailwind css
## Perequisites
You need a couple of packages and settings to integrate this package
### Packages
- React
- React Dom
- Tailwind CSS **version 4**
### CSS Base
This package contains a `base.css` file which has all the base css rules required to run. This css file must be imported in your base project, and it can be update in a separate `.css` file.

173
components/base.css Normal file
View File

@ -0,0 +1,173 @@
@import "tailwindcss";
@theme inline {
--breakpoint-xs: 350px;
--breakpoint-xxs: 300px;
--breakpoint-xxl: 1600px;
--color-background-light: #ffffff;
--color-foreground-light: #171717;
--color-background-dark: #0a0a0a;
--color-foreground-dark: #ededed;
--color-dark: #000000;
--color-primary: #000000;
--color-primary-hover: #29292b;
--color-primary-outline: #29292b;
--color-primary-text: #29292b;
--color-primary-dark: #29292b;
--color-primary-dark-hover: #4b4b4b;
--color-primary-dark-outline: #4b4b4b;
--color-primary-dark-text: #ffffff;
--color-secondary: #000000;
--color-secondary-hover: #dddddd;
--color-secondary-outline: #dddddd;
--color-secondary-text: #dddddd;
--color-secondary-dark: #000000;
--color-secondary-dark-hover: #dddddd;
--color-secondary-dark-outline: #dddddd;
--color-secondary-dark-text: #dddddd;
--color-accent: #000000;
--color-accent-hover: #dddddd;
--color-accent-outline: #dddddd;
--color-accent-text: #dddddd;
--color-accent-dark: #000000;
--color-accent-dark-hover: #dddddd;
--color-accent-dark-outline: #dddddd;
--color-accent-dark-text: #dddddd;
--color-gray: #dfe6ef;
--color-gray-hover: #dfe6ef;
--color-gray-dark: #1d2b3f;
--color-gray-dark-hover: #132033;
--color-success: #0aa156;
--color-success-dark: #0aa156;
--color-error: #e5484d;
--color-error-dark: #e5484d;
--color-warning: #ff6900;
--color-link: #0051c9;
--color-link-dark: #548adb;
--radius-default: 5px;
--radius-default-sm: 3px;
--radius-default-xs: 1px;
--radius-default-lg: 7px;
--radius-default-xl: 10px;
--container-container: 1200px;
--container-modal: 800px;
}
@custom-variant dark (&:where(.dark, .dark *));
body {
@apply bg-background-light dark:bg-background-dark;
@apply text-foreground-light dark:text-foreground-dark;
font-family: Arial, Helvetica, sans-serif;
}
.tox-tinymce {
@apply w-full !rounded-default !border-slate-300 dark:!border-white/20;
}
/* .moving-object {
@apply !bg-green-500;
} */
option {
@apply dark:bg-background-dark;
}
.mobile-paper-hidden {
@apply max-md:p-0 max-md:border-none max-md:bg-transparent;
}
::-webkit-scrollbar {
@apply w-2;
}
::-webkit-scrollbar-track {
@apply bg-gray rounded-full dark:bg-gray;
}
::-webkit-scrollbar-thumb {
@apply bg-foreground-light/40 rounded-full hover:bg-foreground-light/60;
@apply dark:bg-foreground-dark/40 rounded-full hover:bg-foreground-dark/60;
}
* {
scrollbar-width: thin;
scrollbar-color: theme("colors.gray.400") theme("colors.gray.100");
}
@supports (selector(:where(*))) {
:where(*) {
scrollbar-width: thin;
scrollbar-color: theme("colors.gray.400") theme("colors.gray.100");
}
.dark :where(*) {
scrollbar-color: theme("colors.gray.500") theme("colors.gray.800");
}
}
.ace_editor {
@apply dark:bg-background-dark;
}
.tox-editor-header,
.tox-toolbar-overlord,
.tox .tox-toolbar,
.tox .tox-toolbar__overflow,
.tox .tox-toolbar__primary,
.tox .tox-tbtn,
.tox .tox-sidebar,
.tox .tox-statusbar,
.tox .tox-view-wrap,
.tox .tox-view-wrap__slot-container,
.tox .tox-editor-container,
.tox .tox-edit-area__iframe,
.twui-tinymce {
@apply dark:!bg-background-dark;
}
.twui-tinymce *:focus {
@apply !outline-white/10;
}
.tox .tox-tbtn:hover {
@apply dark:!bg-white/10;
}
.ace_gutter {
@apply dark:!bg-background-dark;
}
.ace_active-line,
.ace_gutter-active-line {
@apply dark:!bg-white/5;
}
.normal-text {
@apply text-foreground-light dark:text-foreground-dark;
}
ol {
list-style: decimal;
}
ul {
list-style: disc;
}
ul,
ol {
margin-left: 25px;
}

193
components/bun.lock Normal file
View File

@ -0,0 +1,193 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "tailwind-ui",
"dependencies": {
"@xterm/xterm": "latest",
"html-to-react": "^1.7.0",
"lodash": "latest",
"lucide-react": "latest",
"react-code-blocks": "latest",
"react-responsive-modal": "latest",
"tailwind-merge": "latest",
"typescript": "latest",
},
"devDependencies": {
"@next/mdx": "latest",
"@types/ace": "latest",
"@types/bun": "latest",
"@types/lodash": "latest",
"@types/mdx": "latest",
"@types/node": "latest",
"@types/react": "latest",
"@types/react-dom": "latest",
"postcss": "latest",
"tailwindcss": "^4",
},
},
},
"packages": {
"@babel/runtime": ["@babel/runtime@7.27.1", "", {}, "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog=="],
"@bedrock-layout/use-forwarded-ref": ["@bedrock-layout/use-forwarded-ref@1.6.1", "", { "dependencies": { "@bedrock-layout/use-stateful-ref": "^1.4.1" }, "peerDependencies": { "react": "^16.8 || ^17 || ^18" } }, "sha512-GD9A9AFLzFNjr7k6fgerSqxfwDWl+wsPS11PErOKe1zkVz0y7RGC9gzlOiX/JrgpyB3NFHWIuGtoOQqifJQQpw=="],
"@bedrock-layout/use-stateful-ref": ["@bedrock-layout/use-stateful-ref@1.4.1", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18" } }, "sha512-4eKO2KdQEXcR5LI4QcxqlJykJUDQJWDeWYAukIn6sRQYoabcfI5kDl61PUi6FR6o8VFgQ8IEP7HleKqWlSe8SQ=="],
"@emotion/is-prop-valid": ["@emotion/is-prop-valid@1.2.2", "", { "dependencies": { "@emotion/memoize": "^0.8.1" } }, "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw=="],
"@emotion/memoize": ["@emotion/memoize@0.8.1", "", {}, "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA=="],
"@emotion/unitless": ["@emotion/unitless@0.8.1", "", {}, "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ=="],
"@next/mdx": ["@next/mdx@15.3.2", "", { "dependencies": { "source-map": "^0.7.0" }, "peerDependencies": { "@mdx-js/loader": ">=0.15.0", "@mdx-js/react": ">=0.15.0" }, "optionalPeers": ["@mdx-js/loader", "@mdx-js/react"] }, "sha512-D6lSSbVzn1EiPwrBKG5QzXClcgdqiNCL8a3/6oROinzgZnYSxbVmnfs0UrqygtGSOmgW7sdJJSEOy555DoAwvw=="],
"@types/ace": ["@types/ace@0.0.52", "", {}, "sha512-YPF9S7fzpuyrxru+sG/rrTpZkC6gpHBPF14W3x70kqVOD+ks6jkYLapk4yceh36xej7K4HYxcyz9ZDQ2lTvwgQ=="],
"@types/bun": ["@types/bun@1.2.13", "", { "dependencies": { "bun-types": "1.2.13" } }, "sha512-u6vXep/i9VBxoJl3GjZsl/BFIsvML8DfVDO0RYLEwtSZSp981kEO1V5NwRcO1CPJ7AmvpbnDCiMKo3JvbDEjAg=="],
"@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="],
"@types/lodash": ["@types/lodash@4.17.16", "", {}, "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g=="],
"@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="],
"@types/node": ["@types/node@22.15.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-3vMNr4TzNQyjHcRZadojpRaD9Ofr6LsonZAoQ+HMUa/9ORTPoxVIw0e0mpqWpdjj8xybyCM+oKOUH2vwFu/oEw=="],
"@types/react": ["@types/react@19.1.4", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EB1yiiYdvySuIITtD5lhW4yPyJ31RkJkkDw794LaQYrxCSaQV/47y5o1FMC4zF9ZyjUjzJMZwbovEnT5yHTW6g=="],
"@types/react-dom": ["@types/react-dom@19.1.5", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg=="],
"@types/stylis": ["@types/stylis@4.2.5", "", {}, "sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw=="],
"@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"@xterm/xterm": ["@xterm/xterm@5.5.0", "", {}, "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="],
"body-scroll-lock": ["body-scroll-lock@3.1.5", "", {}, "sha512-Yi1Xaml0EvNA0OYWxXiYNqY24AfWkbA6w5vxE7GWxtKfzIbZM+Qw+aSmkgsbWzbHiy/RCSkUZBplVxTA+E4jJg=="],
"bun-types": ["bun-types@1.2.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-rRjA1T6n7wto4gxhAO/ErZEtOXyEZEmnIHQfl0Dt1QQSB4QV0iP6BZ9/YB5fZaHFQ2dwHFrmPaRQ9GGMX01k9Q=="],
"camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="],
"character-entities": ["character-entities@1.2.4", "", {}, "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw=="],
"character-entities-legacy": ["character-entities-legacy@1.1.4", "", {}, "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA=="],
"character-reference-invalid": ["character-reference-invalid@1.1.4", "", {}, "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg=="],
"classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
"comma-separated-tokens": ["comma-separated-tokens@1.0.8", "", {}, "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw=="],
"css-color-keywords": ["css-color-keywords@1.0.0", "", {}, "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg=="],
"css-to-react-native": ["css-to-react-native@3.2.0", "", { "dependencies": { "camelize": "^1.0.0", "css-color-keywords": "^1.0.0", "postcss-value-parser": "^4.0.2" } }, "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"fault": ["fault@1.0.4", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA=="],
"format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
"hast-util-parse-selector": ["hast-util-parse-selector@2.2.5", "", {}, "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ=="],
"hastscript": ["hastscript@6.0.0", "", { "dependencies": { "@types/hast": "^2.0.0", "comma-separated-tokens": "^1.0.0", "hast-util-parse-selector": "^2.0.0", "property-information": "^5.0.0", "space-separated-tokens": "^1.0.0" } }, "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w=="],
"highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="],
"highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="],
"html-to-react": ["html-to-react@1.7.0", "", { "dependencies": { "domhandler": "^5.0", "htmlparser2": "^9.0", "lodash.camelcase": "^4.3.0" }, "peerDependencies": { "react": "^0.13.0 || ^0.14.0 || >=15" } }, "sha512-b5HTNaTGyOj5GGIMiWVr1k57egAZ/vGy0GGefnCQ1VW5hu9+eku8AXHtf2/DeD95cj/FKBKYa1J7SWBOX41yUQ=="],
"htmlparser2": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="],
"is-alphabetical": ["is-alphabetical@1.0.4", "", {}, "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg=="],
"is-alphanumerical": ["is-alphanumerical@1.0.4", "", { "dependencies": { "is-alphabetical": "^1.0.0", "is-decimal": "^1.0.0" } }, "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A=="],
"is-decimal": ["is-decimal@1.0.4", "", {}, "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw=="],
"is-hexadecimal": ["is-hexadecimal@1.0.4", "", {}, "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lowlight": ["lowlight@1.20.0", "", { "dependencies": { "fault": "^1.0.0", "highlight.js": "~10.7.0" } }, "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw=="],
"lucide-react": ["lucide-react@0.511.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"parse-entities": ["parse-entities@2.0.0", "", { "dependencies": { "character-entities": "^1.0.0", "character-entities-legacy": "^1.0.0", "character-reference-invalid": "^1.0.0", "is-alphanumerical": "^1.0.0", "is-decimal": "^1.0.0", "is-hexadecimal": "^1.0.0" } }, "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
"property-information": ["property-information@5.6.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA=="],
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
"react-code-blocks": ["react-code-blocks@0.1.6", "", { "dependencies": { "@babel/runtime": "^7.10.4", "react-syntax-highlighter": "^15.5.0", "styled-components": "^6.1.0", "tslib": "^2.6.0" }, "peerDependencies": { "react": ">=16" } }, "sha512-ENNuxG07yO+OuX1ChRje3ieefPRz6yrIpHmebQlaFQgzcAHbUfVeTINpOpoI9bSRSObeYo/OdHsporeToZ7fcg=="],
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
"react-responsive-modal": ["react-responsive-modal@6.4.2", "", { "dependencies": { "@bedrock-layout/use-forwarded-ref": "^1.3.1", "body-scroll-lock": "^3.1.5", "classnames": "^2.3.1" }, "peerDependencies": { "react": "^16.8.0 || ^17 || ^18", "react-dom": "^16.8.0 || ^17 || ^18" } }, "sha512-ARjGEKE5Gu5CSvyA8U9ARVbtK4SMAtdXsjtzwtxRlQIHC99RQTnOUctLpl7+/sp1Kg1OJZ6yqvp6ivd4TBueEw=="],
"react-syntax-highlighter": ["react-syntax-highlighter@15.6.1", "", { "dependencies": { "@babel/runtime": "^7.3.1", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.27.0", "refractor": "^3.6.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-OqJ2/vL7lEeV5zTJyG7kmARppUjiB9h9udl4qHQjjgEos66z00Ia0OckwYfRxCSFrW8RJIBnsBwQsHZbVPspqg=="],
"refractor": ["refractor@3.6.0", "", { "dependencies": { "hastscript": "^6.0.0", "parse-entities": "^2.0.0", "prismjs": "~1.27.0" } }, "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA=="],
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
"shallowequal": ["shallowequal@1.1.0", "", {}, "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="],
"source-map": ["source-map@0.7.4", "", {}, "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"space-separated-tokens": ["space-separated-tokens@1.1.5", "", {}, "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA=="],
"styled-components": ["styled-components@6.1.18", "", { "dependencies": { "@emotion/is-prop-valid": "1.2.2", "@emotion/unitless": "0.8.1", "@types/stylis": "4.2.5", "css-to-react-native": "3.2.0", "csstype": "3.1.3", "postcss": "8.4.49", "shallowequal": "1.1.0", "stylis": "4.3.2", "tslib": "2.6.2" }, "peerDependencies": { "react": ">= 16.8.0", "react-dom": ">= 16.8.0" } }, "sha512-Mvf3gJFzZCkhjY2Y/Fx9z1m3dxbza0uI9H1CbNZm/jSHCojzJhQ0R7bByrlFJINnMzz/gPulpoFFGymNwrsMcw=="],
"stylis": ["stylis@4.3.2", "", {}, "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg=="],
"tailwind-merge": ["tailwind-merge@3.3.0", "", {}, "sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ=="],
"tailwindcss": ["tailwindcss@4.1.7", "", {}, "sha512-kr1o/ErIdNhTz8uzAYL7TpaUuzKIE6QPQ4qmSdxnoX/lo+5wmUHQA6h3L5yIqEImSRnAAURDirLu/BgiXGPAhg=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"refractor/prismjs": ["prismjs@1.27.0", "", {}, "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA=="],
"styled-components/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="],
"styled-components/tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="],
}
}

View File

@ -0,0 +1,41 @@
import { DetailedHTMLProps, HTMLAttributes } from "react";
import { DocsLinkType } from ".";
import Stack from "../../layout/Stack";
import TWUIDocsLink from "./TWUIDocsLink";
import { twMerge } from "tailwind-merge";
type Props = DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & {
DocsLinks: DocsLinkType[];
before?: React.ReactNode;
after?: React.ReactNode;
autoExpandAll?: boolean;
};
export default function TWUIDocsAside({
DocsLinks,
after,
before,
autoExpandAll,
...props
}: Props) {
return (
<aside
{...props}
className={twMerge(
"pb-10 hidden xl:flex sticky top-6",
props.className
)}
>
<Stack>
{before}
{DocsLinks.map((link, index) => (
<TWUIDocsLink
docLink={link}
key={index}
autoExpandAll={autoExpandAll}
/>
))}
{after}
</Stack>
</aside>
);
}

View File

@ -0,0 +1,128 @@
import React, {
AnchorHTMLAttributes,
ComponentProps,
DetailedHTMLProps,
} from "react";
import { DocsLinkType } from ".";
import Stack from "../../layout/Stack";
import { twMerge } from "tailwind-merge";
import Row from "../../layout/Row";
import Divider from "../../layout/Divider";
import { ChevronDown, Circle } from "lucide-react";
import Button from "../../layout/Button";
type Props = DetailedHTMLProps<
AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
> & {
docLink: DocsLinkType;
wrapperProps?: ComponentProps<typeof Stack>;
strict?: boolean;
childWrapperProps?: ComponentProps<typeof Stack>;
autoExpandAll?: boolean;
child?: boolean;
};
/**
* # TWUI Docs Left Aside Link
* @note use dataset attribute `data-strict` for strict matching
*
* @className `twui-docs-left-aside-link`
*/
export default function TWUIDocsLink({
docLink,
wrapperProps,
childWrapperProps,
strict,
autoExpandAll,
child,
...props
}: Props) {
const [isActive, setIsActive] = React.useState(false);
const [expand, setExpand] = React.useState(autoExpandAll || false);
const linkRef = React.useRef<HTMLAnchorElement>(null);
React.useEffect(() => {
if (typeof window !== "undefined") {
const basePathMatch = window.location.pathname.includes(
docLink.href
);
const isStrictMatch = Boolean(
linkRef.current?.getAttribute("data-strict")
);
if (strict || isStrictMatch) {
setIsActive(window.location.pathname === docLink.href);
} else {
setIsActive(basePathMatch);
}
if (basePathMatch) {
setExpand(true);
}
}
}, []);
return (
<Stack
className={twMerge("gap-2 w-full", wrapperProps?.className)}
{...wrapperProps}
>
<Row className="flex-nowrap grow justify-between w-full">
{child && <Circle size={6} />}
<a
href={docLink.href}
title={docLink.title}
{...props}
className={twMerge(
"twui-docs-left-aside-link whitespace-nowrap",
"grow overflow-hidden overflow-ellipsis",
isActive ? "active" : "",
props.className
)}
ref={linkRef}
data-strict={strict || docLink.strict}
>
{docLink.title}
</a>
{docLink.children?.[0] && (
<Button
variant="ghost"
color="gray"
className={twMerge(
"p-1 hover:opacity-100",
expand ? "rotate-180 opacity-30" : "opacity-70"
)}
onClick={() => setExpand(!expand)}
title="Docs Aside Links Dropdown Button"
>
<ChevronDown className="text-slate-500" size={20} />
</Button>
)}
</Row>
{docLink.children && expand && (
<Row className="items-stretch gap-4 grow w-full flex-nowrap">
<Stack
className={twMerge(
"gap-2 w-full pl-3",
childWrapperProps?.className
)}
{...childWrapperProps}
>
{docLink.children.map((link, index) => (
<TWUIDocsLink
key={index}
docLink={link}
className="opacity-70"
autoExpandAll={autoExpandAll}
child
/>
))}
</Stack>
</Row>
)}
</Stack>
);
}

View File

@ -0,0 +1,177 @@
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
import { DocsLinkType } from ".";
import Stack from "../../layout/Stack";
import TWUIDocsLink from "./TWUIDocsLink";
import { twMerge } from "tailwind-merge";
import Span from "../../layout/Span";
import Row from "../../layout/Row";
import { ArrowUpRight, LinkIcon, ListIcon } from "lucide-react";
import Link from "../../layout/Link";
type Props = DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & {
before?: React.ReactNode;
after?: React.ReactNode;
autoExpandAll?: boolean;
editPageURL?: string;
};
export default function TWUIDocsRightAside({
after,
before,
autoExpandAll,
editPageURL,
...props
}: Props) {
const [links, setLinks] = React.useState<DocsLinkType[]>([]);
const [ready, setReady] = React.useState(false);
React.useEffect(() => {
if (!ready) return;
const headerHrefs = document.querySelectorAll(
".twui-docs-header-anchor"
);
const linksArr: DocsLinkType[] = [];
for (let i = 0; i < headerHrefs.length; i++) {
const anchorEl = headerHrefs[i] as HTMLAnchorElement;
const isH2Element = anchorEl.querySelector("h2") !== null;
if (isH2Element) {
let newLink: DocsLinkType = {
title: anchorEl.textContent || "",
href: `#${anchorEl.id}`,
};
let nexElIndex = i + 1;
while (nexElIndex < headerHrefs.length) {
const nextElement = headerHrefs[
nexElIndex
] as HTMLAnchorElement;
const nextElementH3 = nextElement.querySelector("h3");
const isNextElementH2 =
nextElement.querySelector("h2") !== null;
if (isNextElementH2) {
break;
}
if (!nextElementH3) {
break;
}
if (!newLink.children) {
newLink.children = [];
}
newLink.children.push({
title: nextElementH3.textContent || "",
href: `#${nextElement.id}`,
});
nexElIndex++;
}
linksArr.push(newLink);
}
}
setLinks(linksArr);
}, [ready]);
React.useEffect(() => {
if (!links.length) return;
const headerHrefs = document.querySelectorAll(
"a.twui-docs-header-anchor"
);
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const id = entry.target.id;
const link = document.querySelector(
`.twui-docs-right-aside a[href="#${id}"]`
);
if (link) {
link.classList.add("active");
}
} else {
const id = entry.target.id;
const link = document.querySelector(
`.twui-docs-right-aside a[href="#${id}"]`
);
if (link) {
link.classList.remove("active");
}
}
});
});
headerHrefs.forEach((headerHref) => {
observer.observe(headerHref);
});
}, [links]);
React.useEffect(() => {
setTimeout(() => {
setReady(true);
}, 100);
}, []);
return (
<aside
{...props}
className={twMerge(
"pb-10 hidden xl:flex min-w-[150px] max-w-[200px]",
"sticky top-6 twui-docs-right-aside",
props.className
)}
>
<Stack className="w-full overflow-hidden">
{before}
<Stack className="w-full">
<Row>
<ListIcon size={12} opacity={0.5} />
<Span size="smaller" variant="faded">
On this page
</Span>
</Row>
{links.map((link, index) => (
<TWUIDocsLink
docLink={link}
key={index}
autoExpandAll
childWrapperProps={{
className: "pl-2",
}}
wrapperProps={{
className:
"[&_svg]:hidden [&_a]:text-xs [&_a]:overflow-hidden [&_a]:overflow-ellipsis",
}}
/>
))}
</Stack>
{after}
{editPageURL && (
<Link
href={editPageURL}
target="_blank"
className="text-[12px] mt-2"
>
<Row className="gap-2">
<LinkIcon size={12} opacity={0.5} />
<span>Edit This Page</span>
<ArrowUpRight size={15} className="-ml-1" />
</Row>
</Link>
)}
</Stack>
</aside>
);
}

View File

@ -0,0 +1,88 @@
import {
ComponentProps,
DetailedHTMLProps,
HTMLAttributes,
PropsWithChildren,
} from "react";
import Stack from "../../layout/Stack";
import Container from "../../layout/Container";
import Row from "../../layout/Row";
import TWUIDocsAside from "./TWUIDocsAside";
import { twMerge } from "tailwind-merge";
import Paper from "../../elements/Paper";
import TWUIDocsRightAside from "./TWUIDocsRightAside";
export type DocsLinkType = {
title: string;
href: string;
strict?: boolean;
children?: DocsLinkType[];
editPage?: string;
};
type Props = PropsWithChildren & {
DocsLinks: DocsLinkType[];
docsAsideBefore?: React.ReactNode;
docsAsideAfter?: React.ReactNode;
wrapperProps?: ComponentProps<typeof Stack>;
docsContentProps?: ComponentProps<typeof Row>;
leftAsideProps?: DetailedHTMLProps<
HTMLAttributes<HTMLElement>,
HTMLElement
>;
autoExpandAll?: boolean;
editPageURL?: string;
};
/**
* # TWUI Docs
* @className `twui-docs-content`
*/
export default function TWUIDocs({
children,
DocsLinks,
docsAsideAfter,
docsAsideBefore,
wrapperProps,
docsContentProps,
leftAsideProps,
autoExpandAll,
editPageURL,
}: Props) {
return (
<Stack
center
{...wrapperProps}
className={twMerge("w-full px-4 sm:px-6", wrapperProps?.className)}
>
<Container>
<Paper className="xl:p-8 mobile-paper-hidden">
<Row
{...docsContentProps}
className={twMerge(
"items-start gap-8 w-full flex-nowrap",
docsContentProps?.className
)}
>
<TWUIDocsAside
DocsLinks={DocsLinks}
after={docsAsideAfter}
before={docsAsideBefore}
autoExpandAll={autoExpandAll}
{...leftAsideProps}
/>
<div
className={twMerge(
"block twui-docs-content pl-0 xl:pl-6 grow",
"overflow-hidden"
)}
>
{children}
</div>
<TWUIDocsRightAside editPageURL={editPageURL} />
</Row>
</Paper>
</Container>
</Stack>
);
}

View File

@ -0,0 +1,172 @@
import React, { MutableRefObject } from "react";
import { twMerge } from "tailwind-merge";
import AceEditorModes from "./ace-editor-modes";
import { AceEditorOptions } from "@moduletrace/datasquirel/dist/package-shared/types";
export type AceEditorComponentType = {
editorRef?: MutableRefObject<AceAjax.Editor | undefined>;
readOnly?: boolean;
/** Function to call when Ctrl+Enter is pressed */
ctrlEnterFn?: (editor: AceAjax.Editor) => void;
content?: string;
placeholder?: string;
title?: string;
mode?: (typeof AceEditorModes)[number];
fontSize?: string;
previewMode?: boolean;
onChange?: (value: string) => void;
delay?: number;
wrapperProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
refreshDepArr?: any[];
editorOptions?: AceEditorOptions;
showLabel?: boolean;
};
let timeout: any;
/**
* # Powerful Ace Editor
* @note **NOTE** head scripts required
* @script `https://cdnjs.cloudflare.com/ajax/libs/ace/1.22.0/ace.min.js`
* @script `https://cdnjs.cloudflare.com/ajax/libs/ace/1.22.0/ext-language_tools.min.js`
*/
export default function AceEditor({
editorRef,
readOnly,
ctrlEnterFn,
content = "",
placeholder,
mode,
fontSize,
previewMode,
onChange,
delay = 500,
refreshDepArr,
wrapperProps,
editorOptions,
showLabel,
title,
}: AceEditorComponentType) {
try {
const editorElementRef = React.useRef<HTMLDivElement>(undefined);
const editorRefInstance = React.useRef<AceAjax.Editor>(undefined);
const [refresh, setRefresh] = React.useState(0);
const [darkMode, setDarkMode] = React.useState(false);
const [ready, setReady] = React.useState(false);
React.useEffect(() => {
if (!ready) return;
if (!ace?.edit || !editorElementRef.current) {
setTimeout(() => {
setRefresh((prev) => prev + 1);
}, 1000);
return;
}
const editor = ace.edit(editorElementRef.current);
editor.setOptions({
mode: `ace/mode/${mode ? mode : "javascript"}`,
theme: darkMode
? "ace/theme/tomorrow_night_eighties"
: "ace/theme/ace_light",
value: (() => {
try {
return JSON.stringify(JSON.parse(content), null, 4);
} catch (error) {
return content;
}
})(),
placeholder: placeholder ? placeholder : "",
enableBasicAutocompletion: true,
enableLiveAutocompletion: true,
readOnly: readOnly ? true : false,
fontSize: fontSize ? fontSize : null,
showLineNumbers: previewMode ? false : true,
wrap: true,
wrapMethod: "code",
...editorOptions,
});
editor.commands.addCommand({
name: "myCommand",
bindKey: { win: "Ctrl-Enter", mac: "Command-Enter" },
exec: function (editor) {
if (ctrlEnterFn) ctrlEnterFn(editor);
},
readOnly: true,
});
editor.getSession().on("change", function (e) {
if (onChange) {
clearTimeout(timeout);
setTimeout(() => {
try {
onChange(editor.getValue());
} catch (error) {}
}, delay);
}
});
editorRefInstance.current = editor;
if (editorRef) editorRef.current = editor;
return function () {
editor.destroy();
};
}, [refresh, darkMode, ready, mode, ...(refreshDepArr || [])]);
React.useEffect(() => {
const htmlClassName = document.documentElement.className;
if (htmlClassName.match(/dark/i)) setDarkMode(true);
setTimeout(() => {
setReady(true);
}, 200);
}, []);
return (
<React.Fragment>
<div
{...wrapperProps}
className={twMerge(
"w-full h-[400px] block rounded-default",
"border border-slate-200 border-solid relative",
"dark:border-white/20",
showLabel && title ? "pt-4" : "",
wrapperProps?.className,
)}
>
{showLabel && title ? (
<label
className={twMerge(
"bg-background-light dark:bg-background-dark text-xs",
"-top-3 left-2 px-2 py-1 absolute z-10",
)}
>
{title}
</label>
) : null}
<div
ref={editorElementRef as any}
className="w-full h-full"
></div>
</div>
</React.Fragment>
);
} catch (error: any) {
return (
<React.Fragment>
<span className="m-0">
Editor Error:{" "}
<b className="text-red-600">{error.message}</b>
</span>
</React.Fragment>
);
}
}

View File

@ -0,0 +1,238 @@
import React, { ComponentProps } from "react";
import { RawEditorOptions, TinyMCE, Editor } from "./tinymce";
import { twMerge } from "tailwind-merge";
import twuiSlugToNormalText from "../../utils/slug-to-normal-text";
import Border from "../../elements/Border";
import useTinyMCE from "./useTinyMCE";
export type TinyMCEEditorProps<KeyType extends string> = {
options?: RawEditorOptions;
editorRef?: React.MutableRefObject<Editor | null>;
setEditor?: React.Dispatch<React.SetStateAction<Editor>>;
wrapperProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
wrapperWrapperProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
borderProps?: ComponentProps<typeof Border>;
defaultValue?: string;
name?: KeyType;
changeHandler?: (content: string) => void;
showLabel?: boolean;
useParentCSS?: boolean;
placeholder?: string;
refreshDependencyArray?: any[];
};
/**
* # Tiny MCE Editor Component
* @className_wrapper twui-rte-wrapper
*/
export default function TinyMCEEditor<KeyType extends string>({
options,
editorRef: passedEditorRef,
setEditor: passedSetEditor,
wrapperProps,
defaultValue,
changeHandler,
wrapperWrapperProps,
borderProps,
name,
showLabel,
useParentCSS,
placeholder,
refreshDependencyArray,
}: TinyMCEEditorProps<KeyType>) {
const { tinyMCE } = useTinyMCE();
const editorComponentRef = React.useRef<HTMLDivElement>(null);
const editorRef = passedEditorRef || React.useRef<Editor>(null);
const EDITOR_VALUE_CHANGE_TIMEOUT = 500;
const FINAL_HEIGHT = options?.height || 500;
const [themeReady, setThemeReady] = React.useState(false);
const [ready, setReady] = React.useState(false);
const [darkMode, setDarkMode] = React.useState(false);
const [refresh, setRefresh] = React.useState(0);
const [editor, setEditor] = React.useState<Editor>();
const title = name ? twuiSlugToNormalText(name) : "Rich Text";
React.useEffect(() => {
if (!tinyMCE) {
return;
}
const htmlClassName = document.documentElement.className;
if (htmlClassName.match(/dark/i)) setDarkMode(true);
setTimeout(() => {
setThemeReady(true);
}, 200);
}, [tinyMCE]);
let valueTimeout: any;
const id = crypto.randomUUID();
React.useEffect(() => {
if (!editorComponentRef.current || !themeReady || !tinyMCE) {
return;
}
const baseUrl =
process.env.NEXT_PUBLIC_TINYMCE_BASE_URL ||
"https://www.datasquirel.com/tinymce-public";
tinyMCE.init({
height: FINAL_HEIGHT,
menubar: false,
plugins:
"advlist lists link image charmap preview anchor searchreplace visualblocks code fullscreen insertdatetime media table code help wordcount",
toolbar:
"undo redo | blocks | bold italic underline link image | bullist numlist outdent indent | removeformat code searchreplace wordcount preview insertdatetime",
content_style:
"body { font-family:Helvetica,Arial,sans-serif; font-size:14px; background-color: transparent }",
init_instance_callback: (editor) => {
setEditor(editor as any);
if (editorRef) {
editorRef.current = editor;
passedSetEditor?.(editor);
}
if (defaultValue) editor.setContent(defaultValue);
setReady(true);
// editor.on("change", (e) => {
// changeHandler?.(editor.getContent());
// });
editor.on("input", (e) => {
if (changeHandler) {
window.clearTimeout(valueTimeout);
valueTimeout = setTimeout(() => {
changeHandler(editor.getContent());
}, EDITOR_VALUE_CHANGE_TIMEOUT);
}
});
if (useParentCSS) {
useParentStyles(editor);
}
},
base_url: baseUrl,
body_class: "twui-tinymce",
placeholder,
relative_urls: true,
remove_script_host: true,
convert_urls: false,
...options,
license_key: "gpl",
target: editorComponentRef.current,
content_css: darkMode ? "dark" : undefined,
skin: darkMode ? "oxide-dark" : undefined,
});
return function () {
if (!ready) return;
const instance = editorComponentRef.current
? tinyMCE?.get(editorComponentRef.current?.id)
: undefined;
instance?.remove();
};
}, [tinyMCE, themeReady, refresh, ...(refreshDependencyArray || [])]);
React.useEffect(() => {
const instance = editorRef.current;
if (instance) {
instance.setContent(defaultValue || "");
}
}, [defaultValue]);
return (
<div
{...wrapperWrapperProps}
className={twMerge(
"relative w-full [&_.tox-tinymce]:!border-none",
"bg-background-light dark:bg-background-dark",
wrapperWrapperProps?.className,
)}
onInput={(e) => {
console.log(`Input Detected`);
}}
>
{showLabel && (
<label
className={twMerge(
"absolute z-10 -top-[7px] left-[10px] px-2 text-xs",
"bg-background-light dark:bg-background-dark text-gray-500",
"dark:text-white/80 rounded",
)}
htmlFor={id}
>
{title}
</label>
)}
<Border
{...borderProps}
className={twMerge(
"dark:border-white/30 p-0 pt-2",
borderProps?.className,
)}
>
<div
{...wrapperProps}
ref={editorComponentRef}
style={{
height:
String(FINAL_HEIGHT).replace(/[^\d]/g, "") + "px",
...wrapperProps?.style,
}}
className={twMerge(
"bg-slate-200 dark:bg-slate-700 rounded-sm w-full",
"twui-rte-wrapper",
)}
id={id}
></div>
</Border>
</div>
);
}
function useParentStyles(editor: Editor) {
const doc = editor.getDoc();
const parentStylesheets = document.styleSheets;
for (const sheet of parentStylesheets) {
try {
if (sheet.href) {
const link = doc.createElement("link");
link.rel = "stylesheet";
link.href = sheet.href;
doc.head.appendChild(link);
} else {
const rules = sheet.cssRules || sheet.rules;
if (rules) {
const style = doc.createElement("style");
for (const rule of rules) {
try {
style.appendChild(doc.createTextNode(rule.cssText));
} catch (e) {
console.warn("Could not copy CSS rule:", rule, e);
}
}
doc.head.appendChild(style);
}
}
} catch (e) {
console.warn("Error processing stylesheet:", sheet, e);
}
}
}

3313
components/editors/TinyMCE/tinymce.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,52 @@
import React from "react";
import { TinyMCE } from "./tinymce";
let interval: any;
export default function useTinyMCE() {
const [tinyMCE, setTinyMCE] = React.useState<TinyMCE>();
const [refresh, setRefresh] = React.useState(0);
const [scriptLoaded, setScriptLoaded] = React.useState(false);
React.useEffect(() => {
if (refresh >= 5) return;
const clientWindow = window as Window & { tinymce?: TinyMCE };
if (clientWindow.tinymce) {
setScriptLoaded(true);
return;
}
const script = document.createElement("script");
const baseUrl =
process.env.NEXT_PUBLIC_TINYMCE_BASE_URL ||
"https://www.datasquirel.com/tinymce-public";
script.src = `${baseUrl}/tinymce.min.js`;
script.async = true;
script.onload = () => {
setScriptLoaded(true);
};
document.head.appendChild(script);
}, [refresh]);
React.useEffect(() => {
if (!scriptLoaded) return;
const clientWindow = window as Window & { tinymce?: TinyMCE };
let tinyMCE = clientWindow.tinymce;
if (tinyMCE) {
setTinyMCE(tinyMCE);
} else {
setRefresh((prev) => prev + 1);
}
}, [scriptLoaded]);
return { tinyMCE };
}

View File

@ -0,0 +1,125 @@
const AceEditorModes = [
"abap",
"abc",
"actionscript",
"ada",
"apache_conf",
"asciidoc",
"assembly_x86",
"autohotkey",
"batchfile",
"c9search",
"c_cpp",
"cirru",
"clojure",
"cobol",
"coffee",
"coldfusion",
"csharp",
"css",
"curly",
"d",
"dart",
"diff",
"dockerfile",
"dot",
"dummy",
"dummysyntax",
"eiffel",
"ejs",
"elixir",
"elm",
"erlang",
"forth",
"ftl",
"gcode",
"gherkin",
"gitignore",
"glsl",
"golang",
"groovy",
"haml",
"handlebars",
"haskell",
"haxe",
"html",
"html_ruby",
"ini",
"io",
"jack",
"jade",
"java",
"javascript",
"json",
"jsoniq",
"jsp",
"jsx",
"julia",
"latex",
"less",
"liquid",
"lisp",
"livescript",
"logiql",
"lsl",
"lua",
"luapage",
"lucene",
"makefile",
"markdown",
"mask",
"matlab",
"mel",
"mushcode",
"mysql",
"nix",
"objectivec",
"ocaml",
"pascal",
"perl",
"pgsql",
"php",
"powershell",
"praat",
"prolog",
"properties",
"protobuf",
"python",
"r",
"rdoc",
"rhtml",
"ruby",
"rust",
"sass",
"scad",
"scala",
"scheme",
"scss",
"sh",
"sjs",
"smarty",
"snippets",
"soy_template",
"space",
"sql",
"stylus",
"svg",
"tcl",
"tex",
"text",
"textile",
"toml",
"twig",
"typescript",
"vala",
"vbscript",
"velocity",
"verilog",
"vhdl",
"xml",
"xquery",
"yaml",
"shell",
] as const;
export default AceEditorModes;

View File

@ -0,0 +1,42 @@
import { DetailedHTMLProps, HTMLAttributes, RefObject } from "react";
import { twMerge } from "tailwind-merge";
export type TWUI_BORDER_PROPS = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
spacing?: "normal" | "loose" | "tight" | "wide" | "tightest";
componentRef?: RefObject<HTMLDivElement>;
};
/**
* # Toggle Component
* @className_wrapper twui-border
*/
export default function Border({
spacing,
componentRef,
...props
}: TWUI_BORDER_PROPS) {
return (
<div
{...props}
className={twMerge(
"relative flex items-center gap-2 border border-solid rounded-default",
"border-slate-200 dark:border-white/10",
spacing
? spacing == "normal"
? "px-3 py-2"
: spacing == "tight"
? "px-2 py-1"
: ""
: "px-3 py-2",
"twui-border",
props.className
)}
ref={componentRef}
>
{props.children}
</div>
);
}

View File

@ -0,0 +1,228 @@
import React, { ComponentProps, ReactNode } from "react";
import Link from "../layout/Link";
import Divider from "../layout/Divider";
import Row from "../layout/Row";
import lowerToTitleCase from "../utils/lower-to-title-case";
import { twMerge } from "tailwind-merge";
import { ChevronLeft } from "lucide-react";
import Button from "../layout/Button";
type LinkObject = {
title: string;
path: string;
};
type Props = {
excludeRegexMatch?: RegExp;
linkProps?: ComponentProps<typeof Link>;
currentLinkProps?: ComponentProps<typeof Link>;
dividerProps?: ComponentProps<typeof Divider>;
backButtonProps?: ComponentProps<typeof Button>;
backButton?: boolean;
pageUrl?: string;
currentTitle?: string;
skipHome?: boolean;
divider?: ReactNode;
};
/**
* # TWUI Breadcrumbs
* @className `twui-breadcrumb-link`
* @className `twui-current-breadcrumb-wrapper`
* @className `twui-breadcrumbs-divider`
* @className `twui-breadcrumbs-back-button`
*/
export default function Breadcrumbs({
excludeRegexMatch,
linkProps,
currentLinkProps,
dividerProps,
backButton,
backButtonProps,
pageUrl,
currentTitle,
skipHome,
divider,
}: Props) {
const [links, setLinks] = React.useState<LinkObject[] | null>(
pageUrl
? twuiBreadcrumbsGenerateLinksFromUrl({ url: pageUrl, skipHome })
: null
);
React.useEffect(() => {
if (links) return;
let pathname = window.location.pathname;
let validPathLinks = twuiBreadcrumbsGenerateLinksFromUrl({
url: pathname,
excludeRegexMatch,
skipHome,
});
setLinks(validPathLinks);
return function () {
setLinks(null);
};
}, []);
if (!links?.[1]) {
return <React.Fragment></React.Fragment>;
}
return (
<nav
className={twMerge(
"overflow-x-auto",
"twui-current-breadcrumb-wrapper"
)}
aria-label="Breadcrumb"
>
<Row
className={twMerge(
"gap-4 flex-nowrap whitespace-nowrap overflow-x-auto overflow-y-hidden w-full"
)}
>
{backButton && (
<React.Fragment>
<Button
variant="ghost"
color="gray"
{...backButtonProps}
className={twMerge(
"p-1 -my-2 -mx-2",
"twui-breadcrumbs-back-button",
backButtonProps?.className
)}
onClick={(e) => {
window.history.back();
backButtonProps?.onClick?.(e);
}}
title="Breadcrumbs Back Button"
beforeIcon={<ChevronLeft size={20} />}
/>
{divider || (
<Divider
vertical
className={twMerge(
"twui-breadcrumbs-divider",
dividerProps?.className
)}
/>
)}
</React.Fragment>
)}
{links.map((linkObject, index, array) => {
const isTarget = array.length - 1 == index;
if (index === links.length - 1) {
return (
<Link
key={index}
href={linkObject.path}
{...linkProps}
{...(isTarget ? currentLinkProps : {})}
className={twMerge(
"text-primary-text/50 dark:text-primary-dark-text/50 text-xs",
"max-w-[200px] text-ellipsis overflow-hidden",
isTarget ? "current" : "",
"twui-breadcrumb-link",
linkProps?.className,
isTarget && currentLinkProps?.className
)}
title={
currentLinkProps?.title || linkObject.title
}
>
{currentTitle || linkObject.title}
</Link>
);
} else {
return (
<React.Fragment key={index}>
<Link
href={linkObject.path}
{...linkProps}
{...(isTarget ? currentLinkProps : {})}
className={twMerge(
"text-xs",
isTarget ? "current" : "",
"twui-breadcrumb-link",
linkProps?.className,
isTarget && currentLinkProps?.className
)}
>
{currentLinkProps?.title ||
linkObject.title}
</Link>
{divider || (
<Divider
vertical
{...dividerProps}
className={twMerge(
"twui-breadcrumbs-divider",
dividerProps?.className
)}
/>
)}
</React.Fragment>
);
}
})}
</Row>
</nav>
);
////////////////////////////////////////
////////////////////////////////////////
////////////////////////////////////////
}
export function twuiBreadcrumbsGenerateLinksFromUrl({
url,
excludeRegexMatch,
skipHome,
}: {
url: string;
excludeRegexMatch?: RegExp;
skipHome?: boolean;
}) {
let pathLinks = url.split("/");
let validPathLinks = [];
if (!skipHome) {
validPathLinks.push({
title: "Home",
path: url.match(/admin/) ? "/admin" : "/",
});
}
pathLinks.forEach((linkText, index, array) => {
if (!linkText?.match(/./)) {
return;
}
if (excludeRegexMatch && excludeRegexMatch.test(linkText)) return;
validPathLinks.push({
title: lowerToTitleCase(linkText),
path: (() => {
let path = "";
for (let i = 0; i < array.length; i++) {
const lnText = array[i];
if (i > index || !lnText.match(/./)) continue;
path += `/${lnText}`;
}
return path;
})(),
});
});
return validPathLinks;
}

View File

@ -0,0 +1,72 @@
import React, {
ComponentProps,
DetailedHTMLProps,
HTMLAttributes,
} from "react";
import { twMerge } from "tailwind-merge";
import Link from "../layout/Link";
type Props = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
variant?: "normal";
href?: string;
linkProps?: ComponentProps<typeof Link>;
noHover?: boolean;
elRef?: React.RefObject<HTMLDivElement>;
linkRef?: React.RefObject<HTMLAnchorElement>;
};
/**
* # General Card
* @className twui-card
* @className twui-card-link
*
* @info use the classname `nested-link` to prevent the card from being clickable when
* a link (or the target element with this calss) inside the card is clicked.
*/
export default function Card({
href,
variant,
linkProps,
noHover,
elRef,
linkRef,
...props
}: Props) {
const component = (
<div
ref={elRef}
{...props}
className={twMerge(
"flex flex-row items-center p-4 rounded-default bg-background-light dark:bg-background-dark",
"border border-slate-200 dark:border-white/10 border-solid",
noHover ? "" : "twui-card",
props.className
)}
>
{props.children}
</div>
);
if (href) {
return (
<Link
ref={linkRef}
href={href}
{...linkProps}
className={twMerge(
"cursor-pointer",
"twui-card",
"twui-card-link",
linkProps?.className
)}
>
{component}
</Link>
);
}
return component;
}

View File

@ -0,0 +1,61 @@
import { ComponentProps, ReactNode } from "react";
import Stack from "../layout/Stack copy";
import Row from "../layout/Row";
import { Check, CheckCircle, CheckCircle2 } from "lucide-react";
import Span from "../layout/Span";
import { twMerge } from "tailwind-merge";
type BulletPoint = {
title: string;
icon?: ReactNode;
};
export type TWUI_CHECK_BULLET_POINTS_PROPS = ComponentProps<typeof Stack> & {
bulletPoints: BulletPoint[];
bulletWrapperProps?: ComponentProps<typeof Row>;
iconProps?: ComponentProps<typeof CheckCircle2>;
titleProps?: ComponentProps<typeof Span>;
};
/**
* # Check Bullet Points Component
* @className_wrapper twui-check-bullet-points-wrapper
*/
export default function CheckBulletPoints({
bulletPoints,
bulletWrapperProps,
iconProps,
titleProps,
...props
}: TWUI_CHECK_BULLET_POINTS_PROPS) {
return (
<Stack {...props} className={twMerge("gap-3", props.className)}>
{bulletPoints.map((bulletPoint, index) => {
return (
<Row
key={index}
{...bulletWrapperProps}
className={twMerge(
"gap-2 xl:flex-nowrap",
bulletWrapperProps?.className
)}
>
{bulletPoint.icon || (
<CheckCircle2
className="text-success min-w-[20px]"
size={20}
{...iconProps}
/>
)}
<Span
{...titleProps}
className={twMerge("", titleProps?.className)}
>
{bulletPoint.title}
</Span>
</Row>
);
})}
</Stack>
);
}

View File

@ -0,0 +1,150 @@
import { Check, Copy } from "lucide-react";
import React, {
DetailedHTMLProps,
HTMLAttributes,
PropsWithChildren,
} from "react";
import { twMerge } from "tailwind-merge";
import Stack from "../layout/Stack";
import Row from "../layout/Row";
import Button from "../layout/Button";
import Divider from "../layout/Divider";
export const TWUIPrismLanguages = ["shell", "javascript"] as const;
type Props = PropsWithChildren &
DetailedHTMLProps<HTMLAttributes<HTMLPreElement>, HTMLPreElement> & {
wrapperProps?: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
"data-title"?: string;
backgroundColor?: string;
singleBlock?: boolean;
language?: (typeof TWUIPrismLanguages)[number];
};
/**
* # CodeBlock
*
* @className `twui-code-block-wrapper`
* @className `twui-code-pre-wrapper`
* @className `twui-code-block-pre`
* @className `twui-code-block-header`
*/
export default function CodeBlock({
children,
wrapperProps,
backgroundColor,
singleBlock,
language,
...props
}: Props) {
const codeRef = React.useRef<HTMLDivElement>(null);
const [copied, setCopied] = React.useState(false);
const title = props?.["data-title"];
const finalBackgroundColor = backgroundColor || "#28272b";
return (
<div
{...wrapperProps}
className={twMerge(
"outline-[1px] outline-slate-200 dark:outline-white/10",
`rounded w-full transition-all items-start`,
"relative max-w-[80vw] sm:max-w-[85vw] xl:max-w-[880px]",
"twui-code-block-wrapper",
wrapperProps?.className
)}
style={{
boxShadow: copied
? "0 0 10px 10px rgba(18, 139, 99, 0.2)"
: undefined,
backgroundColor: finalBackgroundColor,
...props.style,
}}
>
<Stack
className={twMerge(
"gap-0 w-full overflow-x-auto relative",
"max-h-[600px] overflow-y-auto"
)}
>
<Row
className={twMerge(
"w-full px-1 h-10 sticky top-0 py-2",
singleBlock ? "absolute !bg-transparent" : "",
"twui-code-block-header"
)}
style={{
backgroundColor: finalBackgroundColor,
}}
>
{title && <span className="text-white/70">{title}</span>}
<div className="ml-auto">
{copied ? (
<Row>
<span className="text-white text-xs twui-code-block-copied-text">
Copied!
</span>
<div className="w-5 h-5 rounded-full bg-emerald-600 text-white flex items-center justify-center">
<Check size={15} />
</div>
</Row>
) : (
<Button
variant="ghost"
color="gray"
beforeIcon={<Copy size={17} color="white" />}
className="!p-1 !bg-transparent opacity-50"
onClick={() => {
const content =
codeRef.current?.textContent;
if (!content) {
window.alert("No Content to copy");
return;
}
window.navigator.clipboard
.writeText(content)
.then(() => {
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 3000);
});
}}
title="Copy Code Snippet"
/>
)}
</div>
</Row>
{!singleBlock && (
<Divider className="!border-white/10 sticky top-10" />
)}
<div
className={twMerge(
`p-1 w-full [&_pre]:!bg-transparent`,
singleBlock ? "" : "-mt-1",
"twui-code-pre-wrapper"
)}
ref={codeRef as any}
>
<pre
{...props}
className={twMerge(
"!my-0 whitespace-pre-wrap",
language ? `language-${language}` : "",
"twui-code-block-pre",
props.className
)}
>
{children}
</pre>
</div>
</Stack>
</div>
);
}

View File

@ -1,29 +1,101 @@
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
import Toggle, { TWUI_TOGGLE_PROPS } from "./Toggle";
import { Moon, Sun } from "lucide-react";
type Props = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
active?: boolean;
setActive?: React.Dispatch<React.SetStateAction<boolean>>;
iconWrapperProps?: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
defaultScheme?: "light" | "dark";
};
/**
* # Color Scheme Loader
* @className_wrapper twui-color-scheme-selector
*/
export default function ColorSchemeSelector({
toggleProps,
active: initialActive,
setActive: externalSetActive,
iconWrapperProps,
defaultScheme,
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
toggleProps?: TWUI_TOGGLE_PROPS;
}) {
const [active, setActive] = React.useState(false);
}: Props) {
const [active, setActive] = React.useState(initialActive);
React.useEffect(() => {
const isDocumentDark =
document.documentElement.classList.contains("dark");
const isDocumentLight =
document.documentElement.classList.contains("light");
if (isDocumentDark) {
setActive(true);
return;
} else if (isDocumentLight) {
setActive(false);
return;
}
const existingTheme = localStorage.getItem("theme");
if (existingTheme === "dark") {
setActive(true);
} else if (existingTheme === "light") {
setActive(false);
} else if (defaultScheme) {
setActive(defaultScheme == "dark" ? false : true);
} else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
setActive(true);
} else if (typeof active == "undefined") {
setActive(false);
}
}, []);
React.useEffect(() => {
if (typeof active == "undefined") return;
if (active) {
document.documentElement.className = "dark";
localStorage.setItem("theme", "dark");
} else {
document.documentElement.className = "";
document.documentElement.className = "light";
localStorage.setItem("theme", "light");
}
}, [active]);
return (
<div
{...props}
className={twMerge("flex flex-row items-center", props.className)}
className={twMerge(
"flex flex-row items-center",
"twui-color-scheme-selector",
props.className
)}
>
<Toggle active={active} setActive={setActive} {...toggleProps} />
<button
title="Color Scheme Selector Button"
onClick={() => setActive(!active)}
className={twMerge(
"cursor-pointer hover:opacity-70 flex items-center justify-center"
)}
>
<div
{...iconWrapperProps}
className={twMerge(
"w-6 h-6 flex items-center justify-center",
iconWrapperProps?.className
)}
>
{active == false && <Sun />}
{active == true && <Moon />}
</div>
</button>
</div>
);
}

View File

@ -0,0 +1,59 @@
import React, {
ComponentProps,
Dispatch,
ReactNode,
SetStateAction,
} from "react";
import { Copy, LucideProps } from "lucide-react";
import Button from "../layout/Button";
type Props = Omit<ComponentProps<typeof Button>, "title"> & {
slugText: string;
justIcon?: boolean;
noIcon?: boolean;
title?: string;
outlined?: boolean;
successMsg?: string | ReactNode;
icon?: ReactNode;
iconProps?: LucideProps;
setToastOpen?: Dispatch<SetStateAction<boolean>>;
};
export default function CopySlug({
slugText,
justIcon,
noIcon,
title,
outlined,
successMsg,
iconProps,
icon,
setToastOpen,
...props
}: Props) {
return (
<Button
title={title || slugText}
size="smaller"
variant="ghost"
color="gray"
{...props}
onClick={(e) => {
navigator.clipboard.writeText(slugText).then(() => {
setToastOpen?.(false);
setTimeout(() => {
setToastOpen?.(true);
}, 100);
});
props.onClick?.(e);
}}
style={{ ...(outlined ? {} : { padding: 0 }), ...props.style }}
>
{noIcon
? null
: icon || <Copy size={outlined ? 15 : 20} {...iconProps} />}
{!justIcon && (title ? title : "Copy Slug")}
</Button>
);
}

View File

@ -0,0 +1,191 @@
import React, {
DetailedHTMLProps,
HTMLAttributes,
PropsWithChildren,
} from "react";
import { twMerge } from "tailwind-merge";
export const TWUIDropdownContentPositions = [
"left",
"bottom-left",
"top-left",
"top",
"bottom",
"right",
"bottom-right",
"top-right",
"center",
] as const;
export type TWUI_DROPDOWN_PROPS = PropsWithChildren &
DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
target: React.ReactNode;
contentWrapperProps?: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
targetWrapperProps?: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
debounce?: number;
openDebounce?: number;
hoverOpen?: boolean;
above?: boolean;
position?: (typeof TWUIDropdownContentPositions)[number];
topOffset?: number;
externalSetOpen?: React.Dispatch<React.SetStateAction<boolean>>;
externalOpen?: boolean;
keepOpen?: boolean;
disableClickActions?: boolean;
};
/**
* # Toggle Component
* @className_wrapper twui-dropdown-wrapper
* @className_wrapper twui-dropdown-target
* @className_wrapper twui-dropdown-content
*
* @note use the class `cancel-link` to prevent popup open on click
*/
export default function Dropdown({
contentWrapperProps,
targetWrapperProps,
hoverOpen,
above,
debounce = 200,
openDebounce = 200,
target,
position = "center",
topOffset,
externalSetOpen,
keepOpen,
disableClickActions,
externalOpen,
...props
}: TWUI_DROPDOWN_PROPS) {
const [open, setOpen] = React.useState(externalOpen);
let timeout: any;
let openTimeout: any;
const dropdownRef = React.useRef<HTMLDivElement>(null);
const dropdownContentRef = React.useRef<HTMLDivElement>(null);
const handleClickOutside = React.useCallback((e: MouseEvent) => {
const targetEl = e.target as HTMLElement;
const closestWrapper = targetEl.closest(".twui-dropdown-wrapper");
if (!closestWrapper) {
externalSetOpen?.(false);
return setOpen(false);
}
if (closestWrapper && closestWrapper !== dropdownRef.current) {
externalSetOpen?.(false);
return setOpen(false);
}
}, []);
React.useEffect(() => {
if (keepOpen) return;
document.addEventListener("click", handleClickOutside);
return () => {
document.removeEventListener("click", handleClickOutside);
};
}, []);
React.useEffect(() => {
setOpen(externalOpen);
}, [externalOpen]);
return (
<div
{...props}
className={twMerge(
"flex flex-col items-center relative",
"twui-dropdown-wrapper",
props.className
)}
onMouseEnter={() => {
if (!hoverOpen) return;
window.clearTimeout(timeout);
window.clearTimeout(openTimeout);
openTimeout = setTimeout(() => {
externalSetOpen?.(true);
setOpen(true);
}, openDebounce);
}}
onMouseLeave={(e) => {
if (!hoverOpen) return;
window.clearTimeout(openTimeout);
timeout = setTimeout(() => {
externalSetOpen?.(false);
setOpen(false);
}, debounce);
}}
onBlur={() => {
window.clearTimeout(timeout);
}}
ref={dropdownRef}
>
<div
onClick={(e) => {
const targetEl = e.target as HTMLElement | null;
if (targetEl?.closest?.(".cancel-link")) return;
if (disableClickActions) return;
externalSetOpen?.(!open);
setOpen(!open);
}}
className={twMerge(
"cursor-pointer",
"twui-dropdown-target",
targetWrapperProps?.className
)}
>
{target}
</div>
<div
{...contentWrapperProps}
className={twMerge(
"absolute z-10 mt-1",
position == "left"
? "left-[100%] top-[50%] -translate-y-[50%]"
: position == "right"
? "right-[100%] top-[50%] -translate-y-[50%]"
: position == "bottom-left"
? "left-0 top-[100%]"
: position == "bottom-right"
? "right-0 top-[100%]"
: position == "center"
? "left-[50%] -translate-x-[50%] top-[100%]"
: position == "top"
? "left-[50%] -translate-x-[50%] bottom-[100%]"
: "top-[100%]",
above ? "-translate-y-[120%]" : "",
open ? "flex" : "hidden",
"twui-dropdown-content",
contentWrapperProps?.className
)}
onMouseEnter={() => {
if (!hoverOpen) return;
window.clearTimeout(timeout);
}}
onBlur={() => {
if (!hoverOpen) return;
window.clearTimeout(timeout);
}}
style={{
// top: `calc(100% + ${topOffset || 0}px)`,
...contentWrapperProps?.style,
}}
ref={dropdownContentRef}
>
{props.children}
</div>
</div>
);
}

View File

@ -0,0 +1,88 @@
import React, { ComponentProps, PropsWithChildren, ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import Stack from "../layout/Stack";
import Border from "./Border";
import Center from "../layout/Center";
import Row from "../layout/Row";
import Span from "../layout/Span";
import Link from "../layout/Link";
export const ToastStyles = ["normal", "success", "error"] as const;
export const ToastColors = ToastStyles;
export type TWUIEmptyContentProps = ComponentProps<typeof Stack> & {
title: string;
url?: string;
linkProps?: ComponentProps<typeof Link>;
borderProps?: ComponentProps<typeof Border>;
textProps?: ComponentProps<typeof Span>;
contentWrapperProps?: ComponentProps<typeof Row>;
icon?: ReactNode;
};
/**
* # EmptyC ontent Component
* @className twui-empty-content
* @className twui-empty-content-border
* @className twui-empty-content-link
*/
export default function EmptyContent({
title,
url,
linkProps,
icon,
borderProps,
textProps,
contentWrapperProps,
...props
}: TWUIEmptyContentProps) {
const mainComponent = (
<Stack
{...props}
className={twMerge("w-full", "twui-empty-content", props.className)}
>
<Border
{...borderProps}
className={twMerge(
"w-full",
borderProps?.className,
"twui-empty-content-border"
)}
>
<Center>
<Row {...contentWrapperProps}>
{icon && <div className="opacity-50">{icon}</div>}
<Span
size="small"
{...textProps}
className={twMerge(
"opacity-70 text-foreground-light dark:text-foreground-dark",
textProps?.className
)}
>
{title}
</Span>
</Row>
</Center>
</Border>
</Stack>
);
if (url) {
return (
<Link
{...linkProps}
className={twMerge(
"w-full",
"twui-empty-content-link",
linkProps?.className
)}
href={url}
>
{mainComponent}
</Link>
);
}
return mainComponent;
}

View File

@ -0,0 +1,33 @@
import { ComponentProps, DetailedHTMLProps, HTMLAttributes } from "react";
import Link from "../layout/Link";
import { TwuiHeaderLink } from "./HeaderNav";
import { twMerge } from "tailwind-merge";
import Row from "../layout/Row";
export type TWUI_HEADER_LINK_PROPS = ComponentProps<typeof Link> & {
link: TwuiHeaderLink;
};
/**
* # Header Nav Component
* @className_wrapper twui-header-link
*/
export default function HeaderLink({ link, ...props }: TWUI_HEADER_LINK_PROPS) {
return (
<Link
href={link.url}
strict={link.strict}
{...props}
className={twMerge(
"grow p-2 hover:opacity-50",
"twui-header-link",
props.className
)}
>
<Row>
{link.icon}
{link.title}
</Row>
</Link>
);
}

View File

@ -0,0 +1,98 @@
import React, { DetailedHTMLProps, HTMLAttributes, ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import Row from "../layout/Row";
import HeaderNavLinkComponent from "./HeaderNavLinkComponent";
export type TWUI_HEADER_NAV_PROPS = DetailedHTMLProps<
HTMLAttributes<HTMLElement>,
HTMLElement
> & {
headerLinks: TwuiHeaderLink[];
customDropdowns?: {
url: string;
content: ReactNode;
}[];
};
export type TwuiHeaderLink = {
title: string;
url: string;
strict?: boolean;
dropdown?: ReactNode;
children?: TwuiHeaderLink[];
icon?: ReactNode;
};
/**
* # Header Nav Component
* @className twui-header-nav
* @className twui-header-nav-link-component
* @className twui-header-nav-link-icon
* @className twui-header-nav-link-dropdown
*/
export default function HeaderNav({
headerLinks,
customDropdowns,
...props
}: TWUI_HEADER_NAV_PROPS) {
React.useEffect(() => {
twuiAddActiveLinksFn({ selector: ".twui-header-nav-link-component a" });
}, []);
return (
<nav
{...props}
className={twMerge(
"twui-header-nav w-full xl:w-auto",
props.className
)}
>
<Row className="gap-x-2 gap-y-2 flex-col xl:flex-row items-start xl:items-stretch">
{headerLinks.map((link, index) => {
const targetCustomDropdown = customDropdowns?.find(
(d) => d.url == link.url
);
return (
<HeaderNavLinkComponent
link={link}
key={index}
dropdown={targetCustomDropdown?.content}
/>
);
})}
</Row>
</nav>
);
}
type AddActiveLinkParams = {
selector?: string;
wrapperEl?: HTMLElement;
};
export function twuiAddActiveLinksFn({
selector,
wrapperEl,
}: AddActiveLinkParams) {
(wrapperEl || document).querySelectorAll(selector || "a").forEach((ln) => {
const linkEl = ln as HTMLAnchorElement;
const isLinkStrict = linkEl.dataset.strict;
const linkAttr = linkEl.getAttribute("href");
if (window.location.pathname === "/" && linkAttr == "/") {
linkEl.classList.add("active");
} else if (
isLinkStrict &&
linkEl.getAttribute("href") === window.location.pathname
) {
linkEl.classList.add("active");
} else if (
linkAttr &&
window.location.pathname.startsWith(linkAttr) &&
!isLinkStrict
) {
linkEl.classList.add("active");
}
});
}

View File

@ -0,0 +1,141 @@
import React, { DetailedHTMLProps, HTMLAttributes, ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import Row from "../layout/Row";
import HeaderLink from "./HeaderLink";
import { ChevronDown } from "lucide-react";
import Dropdown from "./Dropdown";
import { TwuiHeaderLink } from "./HeaderNav";
import Card from "./Card";
import Stack from "../layout/Stack";
import Button from "../layout/Button";
/**
* # Header Nav Main Link Component
* @className twui-header-nav-link-component
* @className twui-header-nav-link-icon
* @className twui-header-nav-link-dropdown
*/
export default function HeaderNavLinkComponent({
link,
dropdown,
}: {
link: TwuiHeaderLink;
dropdown?: ReactNode;
}) {
const isDropdown = dropdown || link.dropdown || link.children?.[0];
const mainLinkComponent = (
<Row className="gap-0 grow">
<HeaderLink link={link} strict={link.strict} />
{isDropdown && (
<ChevronDown
className={twMerge(
"hidden xl:flex xl:-ml-1",
"twui-header-nav-link-icon"
)}
size={20}
/>
)}
</Row>
);
const [showMobileDropdown, setShowMobileDropdown] = React.useState(false);
return (
<div
className={twMerge(
"relative w-full xl:w-auto [&_a.active]:font-bold",
"twui-header-nav-link-component"
)}
>
{isDropdown ? (
<React.Fragment>
<Stack className="flex xl:hidden w-full">
<Row className="w-full justify-between">
{mainLinkComponent}
<Button
variant="ghost"
onClick={() =>
setShowMobileDropdown(!showMobileDropdown)
}
title="Header Links Dropdown Button"
>
<ChevronDown
className={twMerge(
"twui-header-nav-link-icon !text-link dark:!text-white"
)}
size={20}
/>
</Button>
</Row>
{showMobileDropdown && (
<Stack className="w-full">
{dropdown ? (
dropdown
) : link.children?.[0] ? (
<Card
className={twMerge(
"w-full p-0",
"twui-header-nav-link-dropdown"
)}
>
<Stack className="w-full items-stretch gap-0 py-2">
{link.children.map(
(_ch, _index) => {
return (
<HeaderLink
link={_ch}
key={_index}
className="px-6 py-4"
/>
);
}
)}
</Stack>
</Card>
) : link.dropdown ? (
link.dropdown
) : null}
</Stack>
)}
</Stack>
<Dropdown
target={mainLinkComponent}
position="center"
hoverOpen
className="hidden xl:flex"
>
{dropdown ? (
dropdown
) : link.children?.[0] ? (
<Card
className={twMerge(
"min-w-[200px] mt-2 p-0",
"twui-header-nav-link-dropdown"
)}
>
<Stack className="w-full items-stretch gap-0 py-2">
{link.children.map((_ch, _index) => {
return (
<HeaderLink
link={_ch}
key={_index}
className="px-6 py-4"
/>
);
})}
</Stack>
</Card>
) : link.dropdown ? (
link.dropdown
) : null}
</Dropdown>
</React.Fragment>
) : (
mainLinkComponent
)}
</div>
);
}

View File

@ -0,0 +1,34 @@
import { DetailedHTMLProps, HTMLAttributes } from "react";
import HtmlToReact from "html-to-react";
import { twMerge } from "tailwind-merge";
export type TWUI_TOGGLE_PROPS = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
html: string;
componentRef?: React.RefObject<any>;
};
/**
* # HTML String to React Component
* @className_wrapper twui-html-react
*/
export default function HtmlToReactComponent({
html,
componentRef,
...props
}: TWUI_TOGGLE_PROPS) {
const htmlToReactParser = HtmlToReact.Parser();
const reactElement = htmlToReactParser.parse(html);
return (
<div
{...props}
className={twMerge("", props.className)}
ref={componentRef}
>
{reactElement}
</div>
);
}

View File

@ -0,0 +1,160 @@
import React, {
ComponentProps,
DetailedHTMLProps,
HTMLAttributes,
ReactNode,
} from "react";
import { twMerge } from "tailwind-merge";
import Link from "../layout/Link";
import { twuiAddActiveLinksFn } from "./HeaderNav";
import Row from "../layout/Row";
import Divider from "../layout/Divider";
import Button from "../layout/Button";
export type TWUI_LINK_LIST_LINK_OBJECT = {
title?: string;
component?: ReactNode;
url?: string;
strict?: boolean;
icon?: ReactNode;
iconPosition?: "before" | "after";
linkProps?: ComponentProps<typeof Link>;
buttonProps?: Omit<ComponentProps<typeof Button>, "title">;
linkType?: "button" | "link";
divider?: ReactNode;
onClick?: React.MouseEventHandler<HTMLElement>;
};
export type TWUI_LINK_LIST_PROPS = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
links: (
| TWUI_LINK_LIST_LINK_OBJECT
| TWUI_LINK_LIST_LINK_OBJECT[]
| undefined
)[];
linkProps?: ComponentProps<typeof Link>;
buttonProps?: Omit<ComponentProps<typeof Button>, "title">;
divider?: boolean;
dividerComponent?: ReactNode;
linkType?: "button" | "link";
};
/**
* # Link List Component
* @description A component that renders a list of links.
* @className_wrapper twui-link-list
*/
export default function LinkList({
links,
linkProps,
buttonProps,
divider,
dividerComponent,
linkType,
...props
}: TWUI_LINK_LIST_PROPS) {
const listRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
twuiAddActiveLinksFn({
wrapperEl: listRef.current || undefined,
selector: "a",
});
}, []);
return (
<div
ref={listRef}
{...props}
className={twMerge(
"flex flex-row items-center gap-1",
"twui-link-list",
props.className,
)}
>
{links
.flat()
.filter((ln) => Boolean(ln))
.map((link, index) => {
if (!link) return null;
if (link.divider)
return (
<React.Fragment key={index}>
{link.divider}
</React.Fragment>
);
const finalDivider =
index < links.length - 1 &&
(dividerComponent ? (
dividerComponent
) : divider ? (
<Divider />
) : undefined);
if (linkType == "button" || link.linkType == "button") {
return (
<React.Fragment key={index}>
<Button
title={link.title || "Link Button"}
variant="ghost"
{...buttonProps}
{...link.buttonProps}
className={twMerge(
"p-2 cursor-pointer whitespace-nowrap",
linkProps?.className,
)}
onClick={(e) => {
link.onClick?.(e);
link.buttonProps?.onClick?.(e);
}}
>
<Row>
{link.icon}
{link.component || link.title}
</Row>
</Button>
{finalDivider}
</React.Fragment>
);
}
return (
<React.Fragment key={index}>
<Link
href={link.url}
title={link.title}
{...linkProps}
{...link.linkProps}
className={twMerge(
"p-2 cursor-pointer whitespace-nowrap",
linkProps?.className,
link.linkProps?.className,
)}
strict={link.strict}
onClick={(e) => {
link.onClick?.(e);
link.linkProps?.onClick?.(e);
}}
>
<Row>
{!link.iconPosition ||
link.iconPosition == "before"
? link.icon
: null}
{link.component || link.title}
{link.iconPosition == "after"
? link.icon
: null}
</Row>
</Link>
{finalDivider}
</React.Fragment>
);
})}
</div>
);
}

View File

@ -5,32 +5,44 @@ type Props = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
size?: "small" | "normal" | "medium" | "large";
size?: "small" | "normal" | "medium" | "large" | "smaller";
svgClassName?: string;
};
export default function Loading({ size, ...props }: Props) {
/**
* # Loading Component
* @className_wrapper twui-loading
*/
export default function Loading({ size, svgClassName, ...props }: Props) {
const sizeClassName = (() => {
switch (size) {
case "small":
return "w-2 h-2";
case "normal":
case "smaller":
return "w-4 h-4";
case "small":
return "w-5 h-5";
case "normal":
return "w-6 h-6";
case "large":
return "w-8 h-8";
return "w-7 h-7";
default:
return "w-4 h-4";
return "w-6 h-6";
}
})();
return (
<div role="status" {...props}>
<div
role="status"
{...props}
className={twMerge(`twui-loading`, props.className)}
>
<svg
aria-hidden="true"
className={twMerge(
"text-gray-200 animate-spin dark:text-gray-600 fill-blue-600",
sizeClassName
"text-gray animate-spin dark:text-gray-dark fill-primary",
"dark:fill-white twui-loading",
sizeClassName,
svgClassName,
)}
viewBox="0 0 100 101"
fill="none"

View File

@ -0,0 +1,24 @@
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
/**
* # General paper
* @className_wrapper twui-loading-block
*/
export default function LoadingBlock({
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) {
return (
<div
{...props}
className={twMerge(
"bg-slate-200 dark:bg-white/10",
"rounded animate-pulse w-full h-[60px]",
"twui-loading-block",
props.className
)}
>
{props.children}
</div>
);
}

View File

@ -0,0 +1,46 @@
import { ComponentProps, DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
import Center from "../layout/Center";
import Loading from "./Loading";
import Row from "../layout/Row";
import Span from "../layout/Span";
type Props = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
loadingProps?: ComponentProps<typeof Loading>;
label?: string;
fixed?: boolean;
};
/**
* # Loading Overlay Component
* @className_wrapper twui-loading-overlay
*/
export default function LoadingOverlay({
loadingProps,
label,
fixed,
...props
}: Props) {
return (
<div
{...props}
className={twMerge(
"top-0 left-0 w-full h-full z-[500]",
"bg-background-light/90 dark:bg-background-dark/90",
fixed ? "fixed" : "absolute",
props.className,
"twui-loading-overlay",
)}
>
<Center>
<Row>
<Loading {...loadingProps} />
{label && <Span>{label}</Span>}
</Row>
</Center>
</div>
);
}

View File

@ -0,0 +1,190 @@
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
import ModalComponent from "../(partials)/ModalComponent";
import PopoverComponent from "../(partials)/PopoverComponent";
import { twMerge } from "tailwind-merge";
export const TWUIPopoverStyles = [
"top",
"bottom",
"left",
"right",
"transform",
"bottom-left",
"bottom-right",
] as const;
export const TWUIPopoverTriggers = ["hover", "click"] as const;
export type TWUI_MODAL_PROPS = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
target?: React.ReactNode;
targetRef?: React.RefObject<HTMLDivElement>;
popoverReferenceRef?: React.RefObject<HTMLElement | null>;
targetWrapperProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
setOpen?: React.Dispatch<React.SetStateAction<boolean>>;
open?: boolean;
isPopover?: boolean;
position?: (typeof TWUIPopoverStyles)[number];
trigger?: (typeof TWUIPopoverTriggers)[number];
debounce?: number;
onClose?: () => any;
hoverOpen?: boolean;
};
/**
* # Modal Component
* @ID twui-modal-root
* @className twui-modal-content
* @className twui-modal
* @ID twui-popover-root
* @className twui-popover-content
* @className twui-popover-target
*/
export default function Modal(props: TWUI_MODAL_PROPS) {
const {
target,
targetRef,
targetWrapperProps,
open: existingOpen,
setOpen: existingSetOpen,
isPopover,
popoverReferenceRef,
trigger = "hover",
debounce = 500,
onClose,
hoverOpen,
} = props;
const [ready, setReady] = React.useState(false);
const [open, setOpen] = React.useState(existingOpen || false);
React.useEffect(() => {
const IDName = isPopover ? "twui-popover-root" : "twui-modal-root";
const modalRoot = document.getElementById(IDName);
if (modalRoot) {
if (isPopover) {
modalRoot.style.zIndex = "1000";
}
setReady(true);
} else {
const newModalRootEl = document.createElement("div");
newModalRootEl.id = IDName;
document.body.appendChild(newModalRootEl);
setReady(true);
}
}, []);
React.useEffect(() => {
existingSetOpen?.(open);
if (open == false) onClose?.();
}, [open]);
React.useEffect(() => {
setOpen(existingOpen || false);
}, [existingOpen]);
const finalTargetRef = targetRef || React.useRef<HTMLDivElement>(null);
const finalPopoverReferenceRef = popoverReferenceRef || finalTargetRef;
const popoverTargetActiveRef = React.useRef(false);
const popoverContentActiveRef = React.useRef(false);
let closeTimeout: any;
const popoverEnterFn = React.useCallback((e: any) => {
popoverTargetActiveRef.current = true;
popoverContentActiveRef.current = false;
setOpen(true);
props.onMouseEnter?.(e);
}, []);
const popoverLeaveFn = React.useCallback((e: any) => {
window.clearTimeout(closeTimeout);
closeTimeout = setTimeout(() => {
// if (popoverTargetActiveRef.current) {
// popoverTargetActiveRef.current = false;
// return;
// }
if (popoverContentActiveRef.current) {
popoverContentActiveRef.current = false;
return;
}
setOpen(false);
}, debounce);
props.onMouseLeave?.(e);
}, []);
const handleClickOutside = React.useCallback((e: MouseEvent) => {
const targetEl = e.target as HTMLElement;
const closestWrapper = targetEl.closest(".twui-popover-content");
const closestTarget = targetEl.closest(".twui-popover-target");
if (closestTarget) return;
if (!closestWrapper) {
return setOpen(false);
}
}, []);
React.useEffect(() => {
if (!isPopover) return;
document.addEventListener("click", handleClickOutside);
return () => {
document.removeEventListener("click", handleClickOutside);
};
}, []);
return (
<React.Fragment>
{target ? (
<div
{...targetWrapperProps}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpen(!open);
}}
ref={finalTargetRef}
onMouseEnter={
isPopover && (trigger === "hover" || hoverOpen)
? popoverEnterFn
: targetWrapperProps?.onMouseEnter
}
onMouseLeave={
isPopover && (trigger === "hover" || hoverOpen)
? popoverLeaveFn
: targetWrapperProps?.onMouseLeave
}
className={twMerge(
"twui-popover-target",
targetWrapperProps?.className
)}
>
{target}
</div>
) : null}
{ready ? (
isPopover ? (
<PopoverComponent
{...props}
open={open}
setOpen={setOpen}
targetElRef={finalPopoverReferenceRef}
debounce={debounce}
popoverTargetActiveRef={popoverTargetActiveRef}
popoverContentActiveRef={popoverContentActiveRef}
/>
) : (
<ModalComponent {...props} open={open} setOpen={setOpen} />
)
) : null}
</React.Fragment>
);
}

View File

@ -0,0 +1,126 @@
import React, { ComponentProps, Dispatch, SetStateAction } from "react";
import _ from "lodash";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { twMerge } from "tailwind-merge";
import Row from "../layout/Row";
import Button from "../layout/Button";
import EmptyContent from "./EmptyContent";
import Span from "../layout/Span";
type Props = ComponentProps<typeof Row> & {
page?: number;
setPage?: Dispatch<SetStateAction<number>>;
count?: number;
limit?: number;
};
/**
* # Pagination Component
* @param param0
* @returns
*/
export default function Pagination({
count,
page,
setPage,
limit,
...props
}: Props) {
if (!count || !page || !limit)
return (
<EmptyContent title={`count, page, and limit are all required`} />
);
const isLimit = limit * page >= count;
const pages = Math.ceil(count / limit);
return (
<Row
{...props}
className={twMerge(
"w-full justify-between flex-nowrap",
props.className
)}
>
{pages > 1 && (
<Button
title="Next Page Button"
onClick={() => {
window.scrollTo({ top: 0, behavior: "smooth" });
setPage?.((prev) => prev - 1);
}}
variant="outlined"
size="small"
className={twMerge(
"p-1",
page == 1 ? "opacity-40 pointer-events-none" : ""
)}
>
<ChevronLeft size={20} />
</Button>
)}
<Row className={twMerge("gap-6 w-full flex-nowrap justify-center")}>
<Span size="small" variant="faded">
Page {page} / {pages}
</Span>
{pages > 1 && (
<Row
className={twMerge(
"flex-nowrap overflow-x-auto p-1 max-w-[90%]"
)}
>
{Array(pages)
.fill(0)
.map((p, index) => {
const isCurrent = page == index + 1;
return (
<Button
title={`Page ${index + 1}`}
onClick={() => {
window.scrollTo({
top: 0,
behavior: "smooth",
});
setPage?.(index + 1);
}}
variant={
isCurrent ? "normal" : "outlined"
}
size="small"
color={isCurrent ? "primary" : "gray"}
className={twMerge(
"p-1 w-6 h-6 min-w-6"
)}
key={index}
>
{index + 1}
</Button>
);
})}
</Row>
)}
</Row>
{pages > 1 && (
<Button
title="Next Page Button"
onClick={() => {
window.scrollTo({ top: 0, behavior: "smooth" });
setPage?.((prev) => prev + 1);
}}
variant="outlined"
size="small"
className={twMerge(
"p-1",
isLimit ? "opacity-40 pointer-events-none" : ""
)}
>
<ChevronRight size={20} />
</Button>
)}
</Row>
);
}

View File

@ -0,0 +1,36 @@
import React, { DetailedHTMLProps, HTMLAttributes, RefObject } from "react";
import { twMerge } from "tailwind-merge";
/**
* # General paper
* @className_wrapper twui-paper
*/
export default function Paper({
variant,
linkProps,
componentRef,
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
variant?: "normal";
linkProps?: DetailedHTMLProps<
React.AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
>;
componentRef?: RefObject<HTMLDivElement | null>;
}) {
return (
<div
{...props}
ref={componentRef as any}
className={twMerge(
"flex flex-col items-start p-4 rounded bg-background-light dark:bg-background-dark gap-4",
"border border-slate-200 dark:border-white/10 border-solid w-full",
"relative",
"twui-paper",
props.className
)}
>
{props.children}
</div>
);
}

View File

@ -0,0 +1,9 @@
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
import Modal, { TWUI_MODAL_PROPS } from "./Modal";
/**
* # Popover Component
*/
export default function Popover(props: TWUI_MODAL_PROPS) {
return <Modal {...props} isPopover />;
}

View File

@ -0,0 +1,49 @@
import React from "react";
import { serialize } from "next-mdx-remote/serialize";
import remarkGfm from "remark-gfm";
import rehypePrismPlus from "rehype-prism-plus";
import { MDXRemote, MDXRemoteSerializeResult } from "next-mdx-remote";
import { useMDXComponents } from "../mdx/mdx-components";
export const TWUIPrismLanguages = ["shell", "javascript"] as const;
type Props = {
content: string;
refresh?: number;
};
/**
* # CodeBlock
*
* @className `twui-remote-code-block-wrapper`
*/
export default function RemoteCodeBlock({ content, refresh }: Props) {
const [mdxSource, setMdxSource] =
React.useState<MDXRemoteSerializeResult<any>>();
const { components } = useMDXComponents();
React.useEffect(() => {
serialize(content, {
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypePrismPlus],
},
}).then((mdxSrc) => {
setMdxSource(mdxSrc);
});
}, [refresh]);
if (!mdxSource) {
return null;
}
return (
<MDXRemote
{...mdxSource}
components={{
...components,
}}
/>
);
}

View File

@ -0,0 +1,114 @@
import { twMerge } from "tailwind-merge";
import Input, { InputProps } from "../form/Input";
import Button from "../layout/Button";
import Row from "../layout/Row";
import { Search as SearchIcon } from "lucide-react";
import React, { DetailedHTMLProps } from "react";
let timeout: any;
export type SearchProps<KeyType extends string> = DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
dispatch?: (value?: string) => void;
changeHandler?: (value?: string) => void;
delay?: number;
inputProps?: InputProps<KeyType>;
buttonProps?: DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
>;
loading?: boolean;
placeholder?: string;
componentRef?: React.RefObject<HTMLInputElement | null>;
};
/**
* # Search Component
* @className_wrapper twui-search-wrapper
* @className_circle twui-search-input
* @className_circle twui-search-button
*/
export default function Search<KeyType extends string>({
dispatch,
changeHandler,
delay = 500,
inputProps,
buttonProps,
loading,
placeholder,
componentRef,
...props
}: SearchProps<KeyType>) {
const [input, setInput] = React.useState(
props.defaultValue?.toString() || ""
);
React.useEffect(() => {
clearTimeout(timeout);
timeout = setTimeout(() => {
dispatch?.(input);
changeHandler?.(input);
}, delay);
}, [input]);
const inputRef = componentRef || React.useRef<HTMLInputElement>(null);
// React.useEffect(() => {
// if (props.autoFocus) {
// inputRef.current?.focus();
// }
// }, []);
return (
<Row
{...props}
className={twMerge(
"relative xl:flex-nowrap items-stretch gap-0 flex-nowrap",
"twui-search-wrapper",
props?.className
)}
>
<Input
type="search"
placeholder={placeholder || "Search"}
{...inputProps}
value={input}
onChange={(e) => setInput(e.target.value)}
className={twMerge(
"rounded-r-none!",
"twui-search-input",
inputProps?.className
)}
wrapperProps={{
className: "rounded-r-none!",
}}
componentRef={inputRef}
/>
<Button
loadingProps={{ size: "small" }}
{...buttonProps}
variant="outlined"
color="gray"
className={twMerge(
"rounded-l-none! ml-[1px]",
"twui-search-button",
buttonProps?.className
)}
onClick={() => {
dispatch?.(input);
changeHandler?.(input);
}}
title="Search Button"
loading={loading}
>
<SearchIcon
className="text-slate-800 dark:text-white"
size={20}
/>
</Button>
</Row>
);
}

View File

@ -0,0 +1,24 @@
import React, { DetailedHTMLProps, PropsWithChildren } from "react";
import { twMerge } from "tailwind-merge";
type Props = PropsWithChildren &
DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>;
/**
* # Single Line CodeBlock
*/
export default function SingleLineCodeBlock({ children, ...props }: Props) {
return (
<div
{...props}
className={twMerge(
"[&_.twui-code-block-header]:absolute [&_.twui-code-block-header]:!bg-transparent",
"[&_.twui-code-block-header]:mt-2 [&_.twui-code-block-header]:pr-3 [&_.twui-divider]:hidden",
"[&_pre]:!pr-14 [&_.twui-code-block-copied-text]:!hidden",
props.className
)}
>
{children}
</div>
);
}

View File

@ -0,0 +1,168 @@
import { LucideProps, Star } from "lucide-react";
import React, {
DetailedHTMLProps,
ForwardRefExoticComponent,
HTMLAttributes,
RefAttributes,
} from "react";
import { twMerge } from "tailwind-merge";
type StarProps = {
total?: number;
value?: number;
size?: number;
starProps?: LucideProps;
allowRating?: boolean;
setValueExternal?: React.Dispatch<React.SetStateAction<number>>;
changeHandler?: (value: number) => void;
};
export type TWUI_STAR_RATING_PROPS = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> &
StarProps;
let timeout: any;
/**
* # Star Rating Component
* @className_wrapper twui-star-rating
*/
export default function StarRating({
total = 5,
value = 0,
size,
starProps,
allowRating,
setValueExternal,
changeHandler,
...props
}: TWUI_STAR_RATING_PROPS) {
const totalArray = Array(total).fill(null);
const [finalValue, setFinalValue] = React.useState(value);
const [selectedStarValue, setSelectedStarValue] = React.useState(value);
const starClicked = React.useRef(false);
const sectionHovered = React.useRef(false);
React.useEffect(() => {
window.clearTimeout(timeout);
timeout = setTimeout(() => {
setValueExternal?.(finalValue);
}, 500);
}, [selectedStarValue]);
return (
<div
{...props}
className={twMerge(
"flex flex-row items-center gap-0 -ml-[2px]",
"twui-star-rating",
props.className,
)}
onMouseEnter={() => {
sectionHovered.current = true;
}}
onMouseLeave={() => {
sectionHovered.current = false;
}}
>
{totalArray.map((_, index) => {
const isActive = index + 1 <= finalValue;
return (
<StarComponent
{...{
total,
value,
size,
starProps,
index,
allowRating,
finalValue,
setFinalValue,
starClicked,
selectedStarValue,
sectionHovered,
setSelectedStarValue,
isActive,
changeHandler,
}}
key={index}
/>
);
})}
</div>
);
}
function StarComponent({
size = 20,
starProps,
index,
allowRating,
setFinalValue,
starClicked,
sectionHovered,
setSelectedStarValue,
selectedStarValue,
isActive,
changeHandler,
}: StarProps & {
index: number;
setFinalValue: React.Dispatch<React.SetStateAction<number>>;
setSelectedStarValue: React.Dispatch<React.SetStateAction<number>>;
starClicked: React.MutableRefObject<boolean>;
sectionHovered: React.MutableRefObject<boolean>;
selectedStarValue: number;
isActive: boolean;
}) {
return (
<div
className={twMerge("p-[2px]", allowRating && "cursor-pointer")}
onMouseEnter={() => {
if (!allowRating) return;
setFinalValue(index + 1);
}}
onMouseLeave={() => {
if (!allowRating) return;
setTimeout(() => {
if (sectionHovered.current) {
return;
}
if (!starClicked.current) {
setFinalValue(0);
}
if (selectedStarValue) {
setFinalValue(selectedStarValue);
}
}, 200);
}}
onClick={() => {
if (!allowRating) return;
starClicked.current = true;
setSelectedStarValue(index + 1);
changeHandler?.(index + 1);
}}
>
<Star
size={size}
className={twMerge(
"text-slate-300 dark:text-white/20",
isActive &&
"text-orange-500 dark:text-orange-400 fill-orange-500 dark:fill-orange-400",
// allowRating &&
// "hover:text-orange-500 hover:dark:text-orange-400 hover:fill-orange-500 hover:dark:fill-orange-400",
starProps?.className,
)}
{...starProps}
/>
</div>
);
}

View File

@ -0,0 +1,76 @@
import React from "react";
import EmptyContent from "./EmptyContent";
import { twMerge } from "tailwind-merge";
type Props = {
data?: { [k: string]: any }[];
};
export default function Table({ data }: Props) {
if (!data || data.length === 0) {
return (
<EmptyContent
title="No results"
borderProps={{ className: "!p-2" }}
/>
);
}
const headers = Object.keys(data[0]);
return (
<div className={twMerge("overflow-x-auto w-full")}>
<table
className={twMerge(
"min-w-full divide-y divide-gray dark:divide-gray-dark"
)}
>
<thead className="bg-gray dark:bg-gray-dark">
<tr>
{headers.map((header) => (
<th
key={header}
className={twMerge(
"px-3 py-2 text-left opacity-50",
"font-semibold"
)}
title={header}
>
{header}
</th>
))}
</tr>
</thead>
<tbody
className={twMerge(
"bg-background-light dark:bg-background-dark",
"divide-y divide-gray dark:divide-gray-dark"
)}
>
{data.map((row, index) => (
<tr
key={index}
className={twMerge(
"hover:bg-gray/20 dark:hover:bg-gray-dark/10"
)}
>
{headers.map((header) => (
<td
key={`${header}-${index}`}
className={twMerge(
"px-3 py-2 whitespace-nowrap text-foreground-light",
"dark:text-foreground-dark max-w-[200px] overflow-hidden",
"overflow-ellipsis"
)}
title={row[header]}
>
{row[header]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@ -0,0 +1,176 @@
import React, { DetailedHTMLProps, HTMLAttributes, ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import Border from "./Border";
import Stack from "../layout/Stack";
import Row from "../layout/Row";
import twuiSlugify from "../utils/slugify";
export type TWUITabsObject = {
title: string;
value?: string;
content?: React.ReactNode;
defaultActive?: boolean;
};
export type TWUI_TOGGLE_PROPS = React.ComponentProps<typeof Stack> & {
tabsContentArray: (TWUITabsObject | TWUITabsObject[] | undefined | null)[];
tabsBorderProps?: React.ComponentProps<typeof Border>;
tabsButtonsWrapperProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
centered?: boolean;
debounce?: number;
/**
* React Component to display when switching
*/
switchComponent?: ReactNode;
setActiveValue?: React.Dispatch<React.SetStateAction<string | undefined>>;
changeHandler?: (value: TWUITabsObject) => void;
defaultValue?: string | null;
hrefUpdate?: boolean;
};
/**
* # Tabs Component
* @className twui-tabs-wrapper
* @className twui-tab-buttons
* @className twui-tab-button-active
* @className twui-tab-buttons-wrapper
* @className twui-tab-buttons-container
* @className twui-tabs-border
*/
export default function Tabs({
tabsContentArray,
tabsBorderProps,
tabsButtonsWrapperProps,
centered,
debounce = 100,
switchComponent,
setActiveValue: existingSetActiveValue,
changeHandler,
defaultValue,
hrefUpdate,
...props
}: TWUI_TOGGLE_PROPS) {
const finalTabsContentArray = tabsContentArray
.flat()
.filter((ct) => Boolean(ct?.title)) as TWUITabsObject[];
const values = finalTabsContentArray.map(
(obj) => obj.value || twuiSlugify(obj.title),
);
const defaultActiveObj = finalTabsContentArray.find(
(ctn) => ctn.defaultActive,
);
const [activeValue, setActiveValue] = React.useState(
defaultValue
? defaultValue
: defaultActiveObj
? defaultActiveObj?.value || twuiSlugify(defaultActiveObj.title)
: values[0] || undefined,
);
const [ready, setReady] = React.useState(false);
const targetContent = finalTabsContentArray.find(
(ctn) =>
ctn.value == activeValue || twuiSlugify(ctn.title) == activeValue,
);
React.useEffect(() => {
if (!ready) return;
existingSetActiveValue?.(activeValue);
if (targetContent && activeValue) {
changeHandler?.(targetContent);
if (hrefUpdate) {
const url = new URL(window.location.href);
url.searchParams.set("tab", activeValue);
window.history.pushState({}, "", url);
}
}
}, [activeValue]);
React.useEffect(() => {
if (hrefUpdate) {
const url = new URL(window.location.href);
const activeTab = url.searchParams.get("tab");
if (activeTab && activeValue !== activeTab) {
setActiveValue(undefined);
setActiveValue(activeTab);
}
setTimeout(() => {
setReady(true);
}, 500);
} else {
setReady(true);
}
}, []);
return (
<Stack
{...props}
className={twMerge("w-full", "twui-tabs-wrapper", props.className)}
>
<div
{...tabsButtonsWrapperProps}
className={twMerge(
"w-full",
"twui-tab-buttons-wrapper",
tabsButtonsWrapperProps?.className,
)}
>
<Border
className="p-0 w-full overflow-hidden twui-tabs-border"
{...tabsBorderProps}
>
<Row
className={twMerge(
"gap-0 items-stretch w-full flex-nowrap overflow-x-auto",
centered && "justify-center",
"twui-tab-buttons-container",
)}
>
{values.map((value, index) => {
const targetObject = finalTabsContentArray.find(
(ctn) =>
ctn.value == value ||
twuiSlugify(ctn.title) == value,
);
const isActive = value == activeValue;
return (
<span
className={twMerge(
"px-6 py-2 rounded-default -ml-[1px] whitespace-nowrap",
isActive
? "bg-primary dark:bg-primary-dark text-white outline-none twui-tab-button-active"
: "text-slate-400 dark:text-white/40 hover:text-slate-800 dark:hover:text-white" +
" cursor-pointer",
"twui-tab-buttons",
)}
onClick={() => {
setActiveValue(undefined);
setTimeout(() => {
setActiveValue(value);
}, debounce);
}}
key={index}
>
{targetObject?.title}
</span>
);
})}
</Row>
</Border>
</div>
{activeValue ? targetContent?.content : switchComponent || null}
</Stack>
);
}

104
components/elements/Tag.tsx Normal file
View File

@ -0,0 +1,104 @@
import React, { PropsWithChildren } from "react";
import { twMerge } from "tailwind-merge";
export type TWUITabsObject = {
title: string;
value: string;
content: React.ReactNode;
defaultActive?: boolean;
};
export type TWUI_TOGGLE_PROPS = PropsWithChildren &
React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
color?: "normal" | "secondary" | "error" | "success" | "gray";
variant?: "normal" | "outlined" | "ghost";
href?: string;
newTab?: boolean;
linkProps?: React.DetailedHTMLProps<
React.AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
>;
};
/**
* # Tabs Component
* @className twui-tag
* @className twui-tag-primary-outlined
*/
export default function Tag({
color,
variant,
children,
href,
newTab,
linkProps,
...props
}: TWUI_TOGGLE_PROPS) {
const mainComponent = (
<div
{...props}
className={twMerge(
"text-xs px-2 py-0.5 rounded-full outline-0",
"text-center flex items-center justify-center",
color == "secondary"
? "bg-secondary text-white outline-secbg-secondary"
: color == "success"
? "bg-success outline-success text-white"
: color == "error"
? "bg-orange-700 outline-orange-700"
: color == "gray"
? twMerge(
"bg-slate-100 outline-slate-200 dark:bg-gray-dark dark:outline-gray-dark",
"text-slate-800 dark:text-white"
)
: "bg-primary text-white outline-primbg-primary twui-tag-primary",
variant == "outlined"
? "!bg-transparent outline-1 " +
(color == "secondary"
? "text-secondary"
: color == "success"
? "text-success dark:text-success-dark"
: color == "error"
? "text-orange-700"
: color == "gray"
? "text-slate-700 dark:text-white/80"
: "text-primary dark:text-primary-dark twui-tag-primary-outlined")
: variant == "ghost"
? "!bg-transparent outline-none border-none " +
(color == "secondary"
? "text-secondary"
: color == "success"
? "text-success dark:text-success-dark"
: color == "error"
? "text-orange-700"
: color == "gray"
? "text-slate-700 dark:text-white/80"
: "text-primary dark:text-primary-dark")
: "",
"twui-tag",
props.className
)}
>
{children}
</div>
);
if (href) {
return (
<a
href={href}
target={newTab ? "_blank" : undefined}
{...linkProps}
className={twMerge("hover:opacity-80", linkProps?.className)}
>
{mainComponent}
</a>
);
}
return mainComponent;
}

View File

@ -0,0 +1,117 @@
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
import Card from "./Card";
import { X } from "lucide-react";
import ReactDOM from "react-dom";
import Span from "../layout/Span";
export const ToastStyles = ["normal", "success", "error"] as const;
export const ToastColors = ToastStyles;
export type TWUIToastProps = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
open?: boolean;
setOpen?: React.Dispatch<React.SetStateAction<boolean>>;
closeDispatch?: (open?: boolean) => void;
closeDelay?: number;
color?: (typeof ToastStyles)[number];
};
let interval: any;
let timeout: any;
/**
* # Toast Component
* @className twui-toast-root
* @className twui-toast
* @className twui-toast-success
* @className twui-toast-error
*/
export default function Toast({
open,
setOpen,
closeDelay = 4000,
color,
closeDispatch,
...props
}: TWUIToastProps) {
const [ready, setReady] = React.useState(false);
const IDName = "twui-toast-root";
React.useEffect(() => {
const toastRoot = document.getElementById(IDName);
if (toastRoot) {
setReady(true);
} else {
const newToastRootEl = document.createElement("div");
newToastRootEl.id = IDName;
document.body.appendChild(newToastRootEl);
setReady(true);
}
}, []);
React.useEffect(() => {
if (!ready || !open) return;
timeout = setTimeout(() => {
setOpen?.(false);
closeDispatch?.(open);
}, closeDelay);
return function () {
setOpen?.(false);
closeDispatch?.(open);
};
}, [ready, open]);
if (!ready) return null;
if (!open) return null;
return ReactDOM.createPortal(
<Card
{...props}
className={twMerge(
"fixed bottom-4 right-4 z-[250] border-none",
"pl-6 pr-8 py-4 bg-primary dark:bg-primary-dark",
color == "success"
? "bg-success-dark dark:bg-success-dark twui-toast-success"
: color == "error"
? "bg-error dark:bg-error-dark twui-toast-error"
: "",
props.className,
"twui-toast",
)}
onMouseEnter={() => {
window.clearTimeout(timeout);
}}
onMouseLeave={(e) => {
timeout = setTimeout(() => {
setOpen?.(false);
closeDispatch?.(open);
}, closeDelay);
}}
>
<Span
className={twMerge(
"absolute top-2 right-2 z-[100] cursor-pointer",
"text-white",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpen?.(false);
closeDispatch?.(open);
}}
>
<X size={15} />
</Span>
<Span className={twMerge("text-white! font-semibold")}>
{props.children}
</Span>
</Card>,
document.getElementById(IDName) as HTMLElement,
);
}

View File

@ -6,7 +6,7 @@ export type TWUI_TOGGLE_PROPS = DetailedHTMLProps<
HTMLDivElement
> & {
active?: boolean;
setActive?: React.Dispatch<React.SetStateAction<boolean>>;
setActive?: React.Dispatch<React.SetStateAction<boolean | undefined>>;
circleProps?: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
@ -36,17 +36,21 @@ export default function Toggle({
)}
onClick={() => setActive?.(!active)}
>
<div
{...circleProps}
className={twMerge(
"w-3.5 h-3.5 rounded-full ",
active
? "bg-blue-600 dark:bg-blue-500"
: "bg-slate-300 dark:bg-white/40",
"twui-toggle-circle",
circleProps?.className
)}
></div>
{typeof active == "undefined" ? (
<div className="w-3.5 h-3.5 twui-toggle-circle"></div>
) : (
<div
{...circleProps}
className={twMerge(
"w-3.5 h-3.5 rounded-full ",
active
? "bg-blue-600 dark:bg-blue-500"
: "bg-slate-300 dark:bg-white/40",
"twui-toggle-circle",
circleProps?.className
)}
></div>
)}
</div>
);
}

View File

@ -0,0 +1,81 @@
import { Send, X } from "lucide-react";
import React, { Dispatch, SetStateAction } from "react";
import { ChatCompletionMessageParam } from "openai/resources/index";
import Row from "../../layout/Row";
import Button from "../../layout/Button";
import CopySlug from "../CopySlug";
import AIPromptHistoryModal from "./AIPromptHistoryModal";
type Props = {
streamRes: string;
setStreamRes: Dispatch<SetStateAction<string>>;
setPrompt: Dispatch<SetStateAction<string>>;
loading: boolean;
promptFn: (prompt: string) => void;
history: ChatCompletionMessageParam[];
prompt: string;
currentPromptRef: React.MutableRefObject<string>;
promptInputRef: React.RefObject<HTMLTextAreaElement>;
};
export default function AIPromptActionSection({
streamRes,
setStreamRes,
loading,
promptFn,
history,
prompt,
setPrompt,
currentPromptRef,
promptInputRef,
}: Props) {
return (
<Row className="w-full justify-between">
<Row className="gap-4">
{streamRes.match(/./) && (
<React.Fragment>
<Button
title="Clear AI Result"
variant="ghost"
size="smaller"
color="gray"
className="px-0"
beforeIcon={<X size={20} />}
onClick={() => {
setStreamRes("");
}}
/>
<CopySlug
slugText={streamRes}
justIcon
iconProps={{ size: 18 }}
title="Copy Content"
/>
</React.Fragment>
)}
</Row>
<Row>
<AIPromptHistoryModal history={history} />
</Row>
<Row>
<Button
title="Send Prompt"
beforeIcon={<Send size={20} />}
loading={loading}
className="p-2"
onClick={() => {
currentPromptRef.current = prompt;
setTimeout(() => {
setPrompt("");
if (promptInputRef.current) {
promptInputRef.current.value = "";
}
}, 200);
promptFn(prompt);
}}
loadingProps={{ size: "smaller" }}
/>
</Row>
</Row>
);
}

View File

@ -0,0 +1,104 @@
import { ChatCompletionMessageParam } from "openai/resources/index";
import React from "react";
import Paper from "../Paper";
import Stack from "../../layout/Stack";
import AIPromptPreview from "./AIPromptPreview";
import LoadingOverlay from "../LoadingOverlay";
import Textarea from "../../form/Textarea";
import AIPromptActionSection from "./AIPromptActionSection";
import Card from "../Card";
import Row from "../../layout/Row";
import Span from "../../layout/Span";
import { MessageCircleMore } from "lucide-react";
type Props = {
model?: string;
promptFn: (prompt: string) => void;
history?: ChatCompletionMessageParam[];
loading?: boolean;
mdRes?: string;
setMdRes: React.Dispatch<React.SetStateAction<string>>;
placeholder?: string;
};
export default function AIPromptBlock({
model,
promptFn,
history = [],
loading = false,
mdRes = "",
setMdRes,
placeholder,
}: Props) {
const [prompt, setPrompt] = React.useState("");
const currentPromptRef = React.useRef("");
const promptInputRef = React.useRef<HTMLTextAreaElement>(null);
return (
<Paper className="">
<Stack className="w-full">
{currentPromptRef.current && (
<Row className="w-full justify-end">
<Card className="py-1.5 px-2.5 text-xs">
<Row>
<Span>{currentPromptRef.current}</Span>
<MessageCircleMore
size={15}
opacity={0.5}
className="-mt-px"
/>
</Row>
</Card>
</Row>
)}
<AIPromptPreview
setStreamRes={setMdRes}
streamRes={mdRes}
history={history}
loading={loading}
/>
<Stack className="w-full relative">
{loading && <LoadingOverlay />}
<Textarea
placeholder={
placeholder ||
(model ? `Prompt ${model}` : "Prompt AI")
}
wrapperProps={{ className: "outline-none" }}
wrapperWrapperProps={{ className: "w-full" }}
value={prompt}
onChange={(e) => {
setPrompt(e.target.value);
}}
onKeyDown={(e) => {
if (e.key == "Enter" && !e.ctrlKey && !e.shiftKey) {
e.preventDefault();
currentPromptRef.current = prompt;
setTimeout(() => {
setPrompt("");
if (promptInputRef.current) {
promptInputRef.current.value = "";
}
}, 200);
promptFn(prompt);
}
}}
componentRef={promptInputRef}
// autoFocus
/>
<AIPromptActionSection
loading={loading}
promptFn={promptFn}
setStreamRes={setMdRes}
streamRes={mdRes}
history={history}
prompt={prompt}
setPrompt={setPrompt}
currentPromptRef={currentPromptRef}
promptInputRef={promptInputRef as any}
/>
</Stack>
</Stack>
</Paper>
);
}

View File

@ -0,0 +1,99 @@
import React from "react";
import { ChatCompletionMessageParam } from "openai/resources/index";
import { twMerge } from "tailwind-merge";
import { Bot, User } from "lucide-react";
import Modal from "../Modal";
import Button from "../../layout/Button";
import Stack from "../../layout/Stack";
import H2 from "../../layout/H2";
import Span from "../../layout/Span";
import Divider from "../../layout/Divider";
import Row from "../../layout/Row";
import Card from "../Card";
import Border from "../Border";
import MarkdownEditorPreviewComponent from "../../mdx/markdown/MarkdownEditorPreviewComponent";
type Props = {
history: ChatCompletionMessageParam[];
};
export default function AIPromptHistoryModal({ history }: Props) {
if (!history[0]) return null;
return (
<Modal
target={
<Button
title="View Chat History"
size="smaller"
color="gray"
variant="outlined"
>
View History
</Button>
}
className="max-w-[900px] bg-slate-100 dark:bg-white/5 xl:p-8"
>
<Stack className="gap-10 w-full">
<Stack className="gap-1">
<H2 className="!text-xl m-0">Chat History</H2>
<Span className="text-xs">
AI chat history for this session.
</Span>
</Stack>
<Divider />
{history.map((hst, index) => {
if (hst.role == "user") {
return (
<Row
key={index}
className="w-full items-start justify-end"
>
<Card
className={twMerge(
"bg-background-dark text-foreground-dark dark:!bg-background-light dark:text-foreground-light"
)}
>
{hst.content?.toString()}
</Card>
<Border className="w-10 h-10 rounded-full p-2 items-center justify-center">
<User />
</Border>
</Row>
);
}
return (
<Row
key={index}
className="w-full items-start flex-nowrap"
>
<Stack>
<Border
className={twMerge(
"w-10 h-10 rounded-full items-center justify-center bg-white p-2",
"dark:bg-background-dark"
)}
>
<Bot />
</Border>
</Stack>
<Card className="grow overflow-x-auto xl:p-8">
<MarkdownEditorPreviewComponent
value={hst.content?.toString() || ""}
maxHeight="none"
wrapperProps={{
className:
"border-none p-0 ai-response-content w-full",
}}
/>
</Card>
</Row>
);
})}
</Stack>
</Modal>
);
}

View File

@ -0,0 +1,53 @@
import Divider from "../../layout/Divider";
import Stack from "../../layout/Stack";
import React from "react";
import MarkdownEditorPreviewComponent from "../../mdx/markdown/MarkdownEditorPreviewComponent";
import { ChatCompletionMessageParam } from "openai/resources/index";
type Props = {
streamRes: string;
setStreamRes: React.Dispatch<React.SetStateAction<string>>;
history: ChatCompletionMessageParam[];
loading?: boolean;
};
export default function AIPromptPreview({
setStreamRes,
streamRes,
history,
loading,
}: Props) {
const responseContentRef = React.useRef<HTMLDivElement>(null);
const isContentInterrupted = React.useRef(false);
React.useEffect(() => {
if (isContentInterrupted.current) return;
if (responseContentRef.current) {
responseContentRef.current.scrollTop =
responseContentRef.current.scrollHeight;
}
}, [streamRes]);
if (loading || !streamRes?.match(/./)) return null;
return (
<Stack className="w-full">
<MarkdownEditorPreviewComponent
value={streamRes}
maxHeight="40vh"
wrapperProps={{
className: "border-none p-0 ai-response-content",
componentRef: responseContentRef as any,
onMouseEnter: () => {
isContentInterrupted.current = true;
},
onMouseLeave: () => {
isContentInterrupted.current = false;
},
}}
/>
<Divider />
</Stack>
);
}

View File

@ -0,0 +1,20 @@
import type { LucideProps } from "lucide-react";
import * as icons from "lucide-react";
import React from "react";
export type TWUILucideIconName = keyof typeof icons;
export type TWUILucideIconProps = LucideProps & {
name: TWUILucideIconName;
};
export default function LucideIcon({ name, ...props }: TWUILucideIconProps) {
const IconComponent = icons[name] as any;
if (!IconComponent) {
console.warn(`Lucide icon "${name}" not found`);
return null;
}
return <IconComponent {...props} />;
}

View File

@ -0,0 +1,146 @@
import React, {
ComponentProps,
DetailedHTMLProps,
HTMLAttributes,
ReactNode,
} from "react";
import { twMerge } from "tailwind-merge";
import CheckMarkSVG from "../svgs/CheckMarkSVG";
import Stack from "../layout/Stack";
import Row from "../layout/Row";
import { Info } from "lucide-react";
import Span from "../layout/Span";
export type CheckboxProps = Omit<
React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>,
"title"
> & {
title?: string | ReactNode;
wrapperProps?: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
label?: string | ReactNode;
labelProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
defaultChecked?: boolean;
wrapperClassName?: string;
setChecked?: React.Dispatch<React.SetStateAction<boolean>>;
checked?: boolean;
readOnly?: boolean;
noLabel?: boolean;
size?: number;
changeHandler?: (value: boolean) => void;
info?: string | ReactNode;
wrapperWrapperProps?: ComponentProps<typeof Stack>;
};
/**
* # Checkbox Component
* @className twui-checkbox
* @className twui-checkbox-checked
* @className twui-checkbox-unchecked
*/
export default function Checkbox({
wrapperProps,
label,
labelProps,
size,
wrapperClassName,
defaultChecked,
setChecked: externalSetChecked,
readOnly,
checked: externalChecked,
changeHandler,
info,
wrapperWrapperProps,
noLabel,
title,
...props
}: CheckboxProps) {
const finalSize = size || 20;
const [checked, setChecked] = React.useState(
defaultChecked || externalChecked || false,
);
const finalTitle = title
? title
: `Checkbox-${Math.round(Math.random() * 100000)}`;
React.useEffect(() => {
if (typeof externalChecked == "undefined") return;
setChecked(externalChecked);
}, [externalChecked]);
React.useEffect(() => {
changeHandler?.(checked);
}, [checked]);
return (
<Stack
{...wrapperWrapperProps}
className={twMerge("gap-1.5", wrapperWrapperProps?.className)}
>
<div
{...wrapperProps}
className={twMerge(
"flex items-start md:items-center gap-2 flex-wrap md:flex-nowrap",
readOnly ? "opacity-70 pointer-events-none" : "",
wrapperClassName,
wrapperProps?.className,
)}
onClick={() => {
setChecked(!checked);
externalSetChecked?.(!checked);
}}
>
<div
{...props}
className={twMerge(
"flex items-center justify-center p-[3px] rounded-default",
checked
? "bg-primary twui-checkbox-checked text-white outline-slate-400"
: "dark:outline-white/50 outline-2 -outline-offset-2 twui-checkbox-unchecked",
"twui-checkbox",
props.className,
)}
style={{
minWidth: finalSize + "px",
width: finalSize + "px",
height: finalSize + "px",
...props.style,
}}
>
{checked && <CheckMarkSVG />}
</div>
{!noLabel && (
<Stack className="gap-0.5">
<div
{...labelProps}
className={twMerge(
"select-none whitespace-normal md:whitespace-nowrap",
labelProps?.className,
)}
>
{label || finalTitle}
</div>
</Stack>
)}
</div>
{info && (
<Row className="gap-1 flex-nowrap" title={info.toString()}>
<Info size={13} className="opacity-40 min-w-[20px]" />
<Span size="smaller" className="opacity-70">
{info}
</Span>
</Row>
)}
</Stack>
);
}

View File

@ -0,0 +1,422 @@
import Button from "../layout/Button";
import Stack from "../layout/Stack";
import { FileArchive, FilePlus2, X } from "lucide-react";
import React, { ComponentProps, DetailedHTMLProps, ReactNode } from "react";
import Card from "../elements/Card";
import Span from "../layout/Span";
import Center from "../layout/Center";
import { FileInputToBase64FunctionReturn } from "../utils/form/fileInputToBase64";
import { twMerge } from "tailwind-merge";
import fileInputToBase64 from "../utils/form/fileInputToBase64";
import Row from "../layout/Row";
import Input from "./Input";
import Loading from "../elements/Loading";
import Tag from "../elements/Tag";
type FileInputUtils = {
clearFileInput?: () => void;
};
type ImageUploadProps = DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
onChangeHandler?: (
fileData: FileInputToBase64FunctionReturn | undefined,
inputRef?: React.RefObject<HTMLInputElement | null>,
utils?: FileInputUtils,
) => any;
changeHandler?: (
fileData: FileInputToBase64FunctionReturn | undefined,
inputRef?: React.RefObject<HTMLInputElement | null>,
utils?: FileInputUtils,
) => any;
onClear?: () => void;
fileInputProps?: DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
>;
placeHolderWrapper?: DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
previewImageWrapperProps?: DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
previewImageProps?: DetailedHTMLProps<
React.ImgHTMLAttributes<HTMLImageElement>,
HTMLImageElement
>;
label?: string | ReactNode;
disablePreview?: boolean;
allowedRegex?: RegExp;
externalSetFile?: React.Dispatch<
React.SetStateAction<FileInputToBase64FunctionReturn | undefined>
>;
externalSetFiles?: React.Dispatch<
React.SetStateAction<FileInputToBase64FunctionReturn[] | undefined>
>;
existingFile?: FileInputToBase64FunctionReturn;
existingFileUrl?: string;
icon?: ReactNode;
externalSetFileURL?: React.Dispatch<string | undefined>;
labelSpanProps?: ComponentProps<typeof Span>;
loading?: boolean;
multiple?: boolean;
};
/**
* @note use the `onChangeHandler` prop to grab the parsed base64 image object
*/
export default function FileUpload({
onChangeHandler,
fileInputProps,
placeHolderWrapper,
previewImageWrapperProps,
previewImageProps,
label,
disablePreview,
allowedRegex,
externalSetFile,
externalSetFiles,
existingFile,
existingFileUrl,
icon,
labelSpanProps,
loading,
multiple,
onClear,
changeHandler,
externalSetFileURL,
...props
}: ImageUploadProps) {
const [file, setFile] = React.useState<
FileInputToBase64FunctionReturn | undefined
>(existingFile);
const [fileUrl, setFileUrl] = React.useState<string | undefined>(
existingFileUrl,
);
const [fileDraggedOver, setFileDraggedOver] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const tempFileURLRef = React.useRef<string>("");
React.useEffect(() => {
if (existingFileUrl) {
setFileUrl(existingFileUrl);
}
}, [existingFileUrl]);
React.useEffect(() => {
if (existingFile) {
setFile(existingFile);
}
}, [existingFile]);
function clearFileInput() {
setFile(undefined);
externalSetFile?.(undefined);
onChangeHandler?.(undefined);
changeHandler?.(undefined);
if (inputRef.current) {
inputRef.current.value = "";
}
onClear?.();
}
const fileInputUtils: FileInputUtils = { clearFileInput };
return (
<Stack
{...props}
className={twMerge("w-full h-[300px]", props?.className)}
>
<input
type="file"
multiple={multiple}
className={twMerge("hidden", fileInputProps?.className)}
{...fileInputProps}
onChange={(e) => {
if (multiple) {
(async () => {
const files = e.target.files;
if (!files?.[0]) return;
let filesArr: FileInputToBase64FunctionReturn[] =
[];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const fileObj = await fileInputToBase64({
inputFile: file,
});
filesArr.push(fileObj);
}
externalSetFiles?.(filesArr);
})();
} else {
const inputFile = e.target.files?.[0];
if (!inputFile) return;
fileInputToBase64({ inputFile, allowedRegex }).then(
(res) => {
setFile(res);
externalSetFile?.(res);
onChangeHandler?.(
res,
inputRef,
fileInputUtils,
);
changeHandler?.(res, inputRef, fileInputUtils);
fileInputProps?.onChange?.(e);
},
);
}
}}
ref={inputRef as any}
/>
{loading ? (
<Card className={twMerge("w-full h-full ")}>
<Center>
<Loading />
</Center>
</Card>
) : file ? (
<Card
{...previewImageWrapperProps}
className={twMerge(
"w-full relative h-full items-center justify-center overflow-hidden",
"pb-10",
previewImageWrapperProps?.className,
)}
>
<Stack>
{disablePreview ? (
<Span className="opacity-50" size="small">
Image Uploaded!
</Span>
) : file.fileType?.match(/image/i) ? (
<img
src={file.fileBase64Full}
className="w-full object-contain overflow-hidden"
{...previewImageProps}
/>
) : (
<Stack>
<FileArchive size={36} strokeWidth={1} />
<Stack className="gap-0">
<Span>
{file.file?.name || file.fileName}
</Span>
<Span size="smaller" className="opacity-70">
{file.fileType}
</Span>
</Stack>
</Stack>
)}
<Button
variant="ghost"
className={twMerge(
"absolute p-2 top-2 right-2 z-20 bg-background-light dark:bg-background-dark",
"hover:bg-white dark:hover:bg-black",
)}
onClick={(e) => {
clearFileInput();
}}
title="Cancel File Upload Button"
>
<X className="text-slate-950 dark:text-white" />
</Button>
<Input
value={file.fileName}
onChange={(e) => {
setFile({ ...file, fileName: e.target.value });
externalSetFile?.({
...file,
fileName: e.target.value,
});
}}
/>
</Stack>
</Card>
) : fileUrl ? (
<Card
className="w-full relative h-full items-center justify-center overflow-hidden"
{...previewImageWrapperProps}
>
<Stack className="w-full">
{disablePreview ? (
<Span className="opacity-50" size="small">
Image Uploaded!
</Span>
) : fileUrl.match(/\.pdf$|\.txt$/) ? (
<Row>
<FileArchive size={36} strokeWidth={1} />
<Stack className="gap-0">
<Span size="smaller" className="opacity-70">
{fileUrl}
</Span>
</Stack>
</Row>
) : (
<img
src={fileUrl}
className="w-full object-contain overflow-hidden"
{...previewImageProps}
/>
)}
<Tag
variant="outlined"
color="gray"
className="w-full py-2 text-sm"
>
{fileUrl}
</Tag>
</Stack>
<Button
variant="ghost"
className={twMerge(
"absolute p-2 top-2 right-2 z-20 bg-white dark:bg-black",
"hover:bg-white dark:hover:bg-black",
)}
onClick={(e) => {
setFile(undefined);
externalSetFile?.(undefined);
onChangeHandler?.(undefined);
changeHandler?.(undefined);
setFileUrl(undefined);
}}
title="Cancel File Button"
>
<X className="text-slate-950 dark:text-white" />
</Button>
</Card>
) : (
<Card
className={twMerge(
"w-full h-full cursor-pointer hover:bg-slate-100/50 dark:hover:bg-white/5",
"border-dashed border-2",
fileDraggedOver ? "bg-slate-100 dark:bg-white/10" : "",
placeHolderWrapper?.className,
)}
onClick={(e) => {
inputRef.current?.click();
placeHolderWrapper?.onClick?.(e);
}}
onDragOver={(e) => {
e.preventDefault();
setFileDraggedOver(true);
}}
onDragLeave={(e) => {
setFileDraggedOver(false);
}}
onDrop={(e) => {
e.preventDefault();
setFileDraggedOver(false);
let inputFile: File | null = null;
if (e.dataTransfer.items) {
[...e.dataTransfer.items].forEach((item, i) => {
if (inputFile) return;
if (item.kind === "file") {
const file = item.getAsFile();
inputFile = file;
}
});
} else {
inputFile = e.dataTransfer.files?.[0];
}
if (!inputFile) return;
fileInputToBase64({ inputFile, allowedRegex }).then(
(res) => {
setFile(res);
externalSetFile?.(res);
onChangeHandler?.(res);
changeHandler?.(res);
},
);
}}
{...placeHolderWrapper}
>
<Center
className={twMerge(
fileDraggedOver ? "pointer-events-none" : "",
)}
>
<Stack className="items-center gap-2">
{icon || <FilePlus2 className="text-slate-400" />}
<Span
size="smaller"
variant="faded"
{...labelSpanProps}
>
{label || "Click to Upload File"}
</Span>
{externalSetFileURL ? (
<Row
className="flex-nowrap gap-0 items-stretch"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Input
placeholder="Add Media URL"
className="text-sm"
wrapperProps={{ className: "h-full" }}
wrapperWrapperProps={{
className: "h-full",
}}
changeHandler={(value) => {
tempFileURLRef.current = value;
}}
onKeyUp={(e) => {
if (e.key == "Enter") {
if (tempFileURLRef.current) {
setFileUrl(
tempFileURLRef.current,
);
externalSetFileURL(
tempFileURLRef.current,
);
}
}
}}
/>
<Button
title="Add Media URL"
variant="outlined"
className="py-0 px-3"
color="gray"
onClick={(e) => {
e.preventDefault();
if (tempFileURLRef.current) {
setFileUrl(
tempFileURLRef.current,
);
externalSetFileURL(
tempFileURLRef.current,
);
}
}}
>
<span className="text-2xl">+</span>
</Button>
</Row>
) : null}
</Stack>
</Center>
</Card>
)}
</Stack>
);
}

View File

@ -1,21 +1,49 @@
import { DetailedHTMLProps, FormHTMLAttributes } from "react";
import _ from "lodash";
import { DetailedHTMLProps, FormHTMLAttributes, RefObject } from "react";
import { twMerge } from "tailwind-merge";
type Props<T extends { [key: string]: any } = { [key: string]: any }> =
DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & {
submitHandler?: (e: React.FormEvent<HTMLFormElement>, data: T) => void;
changeHandler?: (e: React.FormEvent<HTMLFormElement>, data: T) => void;
formRef?: RefObject<HTMLFormElement>;
};
/**
* # Form Element
* @className twui-form
*/
export default function Form({
...props
}: DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>) {
export default function Form<
T extends { [key: string]: any } = { [key: string]: any },
>({ formRef, ...props }: Props<T>) {
const finalProps = _.omit(props, ["submitHandler", "changeHandler"]);
return (
<form
{...props}
{...finalProps}
className={twMerge(
"flex flex-col items-stretch gap-2 w-full bg-transparent",
"twui-form",
props.className
props.className,
)}
onSubmit={(e) => {
e.preventDefault();
const formEl = e.target as HTMLFormElement;
const formData = new FormData(formEl);
const data = Object.fromEntries(formData.entries()) as T;
props.submitHandler?.(e, data);
props.onSubmit?.(e);
}}
onChange={(e) => {
e.preventDefault();
const taregtEl = e.target as HTMLElement;
const formEl = taregtEl.closest("form") as HTMLFormElement;
const formData = new FormData(formEl);
const data = Object.fromEntries(formData.entries()) as T;
props.changeHandler?.(e, data);
props.onChange?.(e);
}}
ref={formRef}
>
{props.children}
</form>

View File

@ -0,0 +1,255 @@
import Button from "../layout/Button";
import Stack from "../layout/Stack";
import { ImagePlus, X } from "lucide-react";
import React, { DetailedHTMLProps } from "react";
import Card from "../elements/Card";
import Span from "../layout/Span";
import Center from "../layout/Center";
import imageInputToBase64, {
ImageInputToBase64FunctionReturn,
} from "../utils/form/imageInputToBase64";
import { twMerge } from "tailwind-merge";
import Tag from "../elements/Tag";
import Input from "./Input";
import Row from "../layout/Row";
type ImageUploadProps = DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
onChangeHandler?: (
imgData: ImageInputToBase64FunctionReturn | undefined,
) => any;
fileInputProps?: DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
>;
placeHolderWrapper?: DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
previewImageWrapperProps?: DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
previewImageProps?: DetailedHTMLProps<
React.ImgHTMLAttributes<HTMLImageElement>,
HTMLImageElement
>;
label?: string;
disablePreview?: boolean;
multiple?: boolean;
existingImageUrl?: string;
externalSetImage?: React.Dispatch<
React.SetStateAction<ImageInputToBase64FunctionReturn | undefined>
>;
externalSetImages?: React.Dispatch<
React.SetStateAction<ImageInputToBase64FunctionReturn[] | undefined>
>;
setLoading?: React.Dispatch<React.SetStateAction<boolean>>;
setImgURL?: React.Dispatch<React.SetStateAction<string | undefined>>;
externalImage?: ImageInputToBase64FunctionReturn;
restoreImageFn?: () => void;
};
/**
* @note use the `onChangeHandler` prop to grab the parsed base64 image object
*/
export default function ImageUpload({
onChangeHandler,
fileInputProps,
placeHolderWrapper,
previewImageWrapperProps,
previewImageProps,
label,
disablePreview,
existingImageUrl,
externalSetImage,
externalSetImages,
externalImage,
multiple,
restoreImageFn,
setLoading,
setImgURL,
...props
}: ImageUploadProps) {
const [imageObject, setImageObject] = React.useState<
ImageInputToBase64FunctionReturn | undefined
>(externalImage);
const [src, setSrc] = React.useState<string | undefined>(existingImageUrl);
const inputRef = React.useRef<HTMLInputElement>(null);
const imageUrlRef = React.useRef("");
React.useEffect(() => {
setImgURL?.(src);
}, [src]);
React.useEffect(() => {
if (existingImageUrl) setSrc(existingImageUrl);
}, [existingImageUrl]);
return (
<Stack
{...props}
className={twMerge(
"w-full h-[300px] overflow-hidden",
props?.className,
)}
>
<input
type="file"
className={twMerge("hidden", fileInputProps?.className)}
multiple={multiple}
accept="image/*"
{...fileInputProps}
onChange={(e) => {
setLoading?.(true);
if (multiple) {
(async () => {
const files = e.target.files;
if (!files?.[0]) return;
let imgArr: ImageInputToBase64FunctionReturn[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const fileObj = await imageInputToBase64({
file,
});
imgArr.push(fileObj);
}
externalSetImages?.(imgArr);
setLoading?.(false);
})();
} else {
imageInputToBase64({ imageInput: e.target }).then(
(res) => {
setSrc(res.imageBase64Full);
onChangeHandler?.(res);
setImageObject?.(res);
externalSetImage?.(res);
fileInputProps?.onChange?.(e);
setLoading?.(false);
},
);
}
}}
ref={inputRef as any}
/>
{src || imageObject?.imageBase64Full ? (
<Card
className="w-full relative h-full items-center justify-center"
{...previewImageWrapperProps}
>
{label && (
<label
className={twMerge(
"absolute top-0 left-0 text-xs z-50",
)}
>
<Tag color="gray">
<span className="opacity-70">{label}</span>
</Tag>
</label>
)}
{disablePreview ? (
<Span className="opacity-50" size="small">
Image Uploaded!
</Span>
) : (
<img
src={imageObject?.imageBase64Full || src}
className="w-full h-full object-contain"
{...previewImageProps}
/>
)}
<div
className={twMerge(
"absolute p-1 top-2 right-2 z-20 bg-background-light dark:bg-background-dark",
"cursor-pointer",
)}
onClick={(e) => {
setSrc(undefined);
onChangeHandler?.(undefined);
setImageObject?.(undefined);
externalSetImage?.(undefined);
if (inputRef.current) {
inputRef.current.value == "";
}
}}
title="Cancel Image Upload Button"
>
<X className="text-slate-950 dark:text-white" />
</div>
</Card>
) : (
<Card
className={twMerge(
"w-full h-full cursor-pointer hover:bg-slate-100 dark:hover:bg-white/20",
placeHolderWrapper?.className,
)}
onClick={(e) => {
const targetEl = e.target as HTMLElement | undefined;
if (targetEl?.closest(".cancel-upload")) {
e.preventDefault();
return;
}
inputRef.current?.click();
placeHolderWrapper?.onClick?.(e);
}}
{...placeHolderWrapper}
>
<Center>
<Stack className="items-center gap-2">
<ImagePlus className="text-slate-400" />
<Span size="smaller" variant="faded">
{label || "Click to Upload Image"}
</Span>
<Stack className="cancel-upload w-full items-stretch gap-1">
<Input
placeholder="Eg. https://example.com/img.png"
className="text-sm twui-image-url-input"
title="Enter Image URL"
wrapperWrapperProps={{ className: "mt-2" }}
changeHandler={(value) => {
imageUrlRef.current = value;
}}
showLabel
/>
<Button
title="Restore Image Button"
size="smaller"
variant="outlined"
color="gray"
onClick={() => {
if (!imageUrlRef.current) return;
setSrc(imageUrlRef.current);
}}
>
Set Image URL
</Button>
</Stack>
{existingImageUrl && (
<Button
title="Restore Image Button"
size="smaller"
variant="ghost"
onClick={() => {
restoreImageFn?.() ||
setSrc(existingImageUrl);
}}
className="cancel-upload"
>
Restore Original Image
</Button>
)}
</Stack>
</Center>
</Card>
)}
</Stack>
);
}

View File

@ -1,26 +0,0 @@
import { DetailedHTMLProps, InputHTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
/**
* # Input Element
* @className twui-input
*/
export default function Input({
...props
}: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) {
return (
<input
{...props}
className={twMerge(
"w-full px-4 py-2 border rounded-md",
"border-slate-300 dark:border-white/20",
"focus:border-slate-700 dark:focus:border-white/50",
"outline-slate-300 dark:outline-white/20",
"focus:outline-slate-700 dark:focus:outline-white/50",
"bg-white dark:bg-black",
"twui-input",
props.className
)}
/>
);
}

View File

@ -0,0 +1,125 @@
import React from "react";
import Row from "../../layout/Row";
import { Info, Minus, Plus } from "lucide-react";
import twuiNumberfy from "../../utils/numberfy";
import { InputProps } from ".";
let pressInterval: any;
let pressTimeout: any;
type Props = Pick<InputProps<any>, "min" | "max" | "step"> & {
setValue: React.Dispatch<React.SetStateAction<string>>;
getNormalizedValue: (v: string) => void;
buttonDownRef: React.MutableRefObject<boolean>;
inputRef: React.RefObject<HTMLInputElement | null>;
};
/**
* # Input Number Text Buttons
*/
export default function NumberInputButtons({
getNormalizedValue,
setValue,
min,
max,
step,
buttonDownRef,
inputRef,
}: Props) {
const PRESS_TRIGGER_TIMEOUT = 200;
const DEFAULT_STEP = 1;
function incrementDownPress() {
window.clearTimeout(pressTimeout);
pressTimeout = setTimeout(() => {
buttonDownRef.current = true;
pressInterval = setInterval(() => {
increment();
}, 50);
}, PRESS_TRIGGER_TIMEOUT);
}
function incrementDownCancel() {
buttonDownRef.current = false;
window.clearTimeout(pressTimeout);
window.clearInterval(pressInterval);
}
function decrementDownPress() {
pressTimeout = setTimeout(() => {
buttonDownRef.current = true;
pressInterval = setInterval(() => {
decrement();
}, 50);
}, PRESS_TRIGGER_TIMEOUT);
}
function decrementDownCancel() {
buttonDownRef.current = false;
window.clearTimeout(pressTimeout);
window.clearInterval(pressInterval);
}
function increment() {
const existingValue = inputRef.current?.value;
const existingNumberValue = twuiNumberfy(existingValue);
if (max && existingNumberValue >= twuiNumberfy(max)) {
return setValue(String(max));
} else if (min && existingNumberValue < twuiNumberfy(min)) {
return setValue(String(min));
} else {
setValue(
String(
existingNumberValue + twuiNumberfy(step || DEFAULT_STEP),
),
);
}
}
function decrement() {
const existingValue = inputRef.current?.value;
const existingNumberValue = twuiNumberfy(existingValue);
if (min && existingNumberValue <= twuiNumberfy(min)) {
setValue(String(min));
} else {
setValue(
String(
existingNumberValue - twuiNumberfy(step || DEFAULT_STEP),
),
);
}
}
return (
<Row className="flex-nowrap gap-1 -my-2 ml-auto -mr-2">
<Row
className="rounded-full w-8 h-8 cursor-pointer touch-none select-none justify-center"
onClick={(e) => {
e.preventDefault();
decrement();
}}
onMouseDown={decrementDownPress}
onTouchStart={decrementDownPress}
onMouseUp={decrementDownCancel}
onTouchEnd={decrementDownCancel}
>
<Minus size={20} />
</Row>
<Row
className="rounded-full w-8 h-8 cursor-pointer touch-none select-none justify-center"
onClick={(e) => {
e.preventDefault();
increment();
}}
onMouseDown={incrementDownPress}
onTouchStart={incrementDownPress}
onMouseUp={incrementDownCancel}
onTouchEnd={incrementDownCancel}
>
<Plus size={20} />
</Row>
</Row>
);
}

View File

@ -0,0 +1,503 @@
import React, {
ComponentProps,
DetailedHTMLProps,
InputHTMLAttributes,
LabelHTMLAttributes,
ReactNode,
RefObject,
TextareaHTMLAttributes,
} from "react";
import { twMerge } from "tailwind-merge";
import Span from "../../layout/Span";
import Button from "../../layout/Button";
import { Eye, EyeOff, Info, InfoIcon, X } from "lucide-react";
import { AutocompleteOptions } from "../../types";
import twuiNumberfy from "../../utils/numberfy";
import Dropdown from "../../elements/Dropdown";
import Card from "../../elements/Card";
import Stack from "../../layout/Stack";
import NumberInputButtons from "./NumberInputButtons";
import twuiSlugToNormalText from "../../utils/slug-to-normal-text";
import twuiUseReady from "../../hooks/useReady";
import Row from "../../layout/Row";
import Paper from "../../elements/Paper";
import { TWUISelectValidityObject } from "../Select";
let timeout: any;
let validationFnTimeout: any;
let externalValueChangeTimeout: any;
export type InputProps<KeyType extends string> = Omit<
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
"prefix" | "suffix"
> &
Omit<
DetailedHTMLProps<
TextareaHTMLAttributes<HTMLTextAreaElement>,
HTMLTextAreaElement
>,
"prefix" | "suffix"
> & {
label?: string;
variant?: "normal" | "warning" | "error" | "inactive";
prefix?: string | ReactNode;
suffix?: string | ReactNode;
suffixProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
showLabel?: boolean;
istextarea?: boolean;
wrapperProps?: DetailedHTMLProps<
InputHTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
wrapperWrapperProps?: ComponentProps<typeof Stack>;
labelProps?: DetailedHTMLProps<
LabelHTMLAttributes<HTMLLabelElement>,
HTMLLabelElement
>;
componentRef?: RefObject<any>;
validationRegex?: RegExp;
debounce?: number;
invalidMessage?: string;
validationFunction?: (
value: string,
element?: HTMLInputElement | HTMLTextAreaElement,
) => Promise<TWUISelectValidityObject>;
changeHandler?: (value: string) => void;
autoComplete?: (typeof AutocompleteOptions)[number];
name?: KeyType;
valueUpdate?: string;
numberText?: boolean;
rawNumber?: boolean;
setReady?: React.Dispatch<React.SetStateAction<boolean>>;
decimal?: number;
info?: string | ReactNode;
ready?: boolean;
validity?: TWUISelectValidityObject;
clearInputProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
};
let refreshes = 0;
/**
* # Input Element
* @className twui-input
* @className twui-input-wrapper
* @className twui-input-invalid
* @className twui-clear-input-field-button
*/
export default function Input<KeyType extends string>(
inputProps: InputProps<KeyType>,
) {
const {
label,
variant,
prefix,
suffix,
componentRef,
labelProps,
wrapperProps,
wrapperWrapperProps,
showLabel,
istextarea,
debounce,
invalidMessage: initialInvalidMessage,
autoComplete,
validationFunction,
validationRegex,
valueUpdate,
numberText,
decimal,
suffixProps,
ready: existingReady,
setReady: externalSetReady,
info,
changeHandler,
validity: existingValidity,
clearInputProps,
rawNumber,
...props
} = inputProps;
function getFinalValue(v: any) {
if (rawNumber) return twuiNumberfy(v);
if (numberText) {
return (
twuiNumberfy(v, decimal).toLocaleString() +
(String(v).match(/\.$/) ? "." : "")
);
}
return v;
}
const defaultInitialValue =
props.defaultValue || props.value
? getFinalValue(props.defaultValue || props.value)
: undefined;
const [validity, setValidity] = React.useState<TWUISelectValidityObject>(
existingValidity || {
isValid: true,
},
);
const inputRef = componentRef || React.useRef<HTMLInputElement>(null);
const textAreaRef = componentRef || React.useRef<HTMLTextAreaElement>(null);
const buttonDownRef = React.useRef(false);
const [value, setValue] = React.useState(
props.defaultValue ? String(props.defaultValue) : "",
);
const [focus, setFocus] = React.useState(false);
const [inputType, setInputType] = React.useState(
numberText ? "text" : props.type,
);
const DEFAULT_DEBOUNCE = 500;
const finalDebounce = debounce || DEFAULT_DEBOUNCE;
const finalLabel =
label ||
props.title ||
props.placeholder ||
(props.name ? twuiSlugToNormalText(props.name) : undefined);
function getNormalizedValue(value: string) {
if (numberText) {
if (props.max && twuiNumberfy(value) > twuiNumberfy(props.max))
return getFinalValue(props.max);
if (props.min && twuiNumberfy(value) < twuiNumberfy(props.min))
return getFinalValue(props.min);
return getFinalValue(value);
} else {
return value;
}
}
React.useEffect(() => {
if (!existingValidity) return;
setValidity(existingValidity);
}, [existingValidity]);
const updateValueFn = (val: string) => {
if (buttonDownRef.current) return;
if (changeHandler) {
window.clearTimeout(externalValueChangeTimeout);
externalValueChangeTimeout = setTimeout(() => {
changeHandler(val);
}, finalDebounce);
}
if (typeof val == "string") {
if (!val.match(/./)) {
setValidity({ isValid: true });
setValue("");
if (istextarea && textAreaRef.current) {
textAreaRef.current.value = "";
} else if (inputRef?.current) {
inputRef.current.value = "";
}
return;
}
window.clearTimeout(timeout);
if (validationRegex) {
timeout = setTimeout(() => {
setValidity({
isValid: validationRegex.test(val),
msg: "Value mismatch",
});
}, finalDebounce);
}
if (validationFunction) {
window.clearTimeout(validationFnTimeout);
validationFnTimeout = setTimeout(() => {
if (validationRegex && !validationRegex.test(val)) {
return;
}
validationFunction(val).then((res) => {
setValidity(res);
});
}, finalDebounce);
}
}
};
React.useEffect(() => {
if (typeof props.value !== "string" || !props.value.match(/./)) return;
setValue(String(props.value));
}, [props.value]);
React.useEffect(() => {
if (istextarea && textAreaRef.current) {
} else if (inputRef?.current) {
inputRef.current.value = getFinalValue(value);
}
updateValueFn(value);
}, [value]);
function handleValueChange(
e: React.ChangeEvent<HTMLInputElement> &
React.ChangeEvent<HTMLTextAreaElement>,
) {
const newValue = e.target.value;
setValue(newValue);
props.onChange?.(e);
}
// function updateValue(
// v: string,
// el?: HTMLInputElement | HTMLTextAreaElement,
// ) {
// if (istextarea && textAreaRef.current) {
// } else if (inputRef?.current) {
// inputRef.current.value = getFinalValue(v);
// }
// updateValueFn(v);
// }
const targetComponent = istextarea ? (
<textarea
placeholder={
props.name ? twuiSlugToNormalText(props.name) : undefined
}
{...props}
className={twMerge(
"w-full outline-none bg-transparent grow",
"twui-textarea",
props.className,
)}
ref={textAreaRef}
onFocus={(e) => {
setFocus(true);
props?.onFocus?.(e);
}}
onBlur={(e) => {
setFocus(false);
props?.onBlur?.(e);
}}
onChange={handleValueChange}
autoComplete={autoComplete}
rows={props.height ? Number(props.height) : props.rows || 2}
defaultValue={defaultInitialValue}
value={props.value ? getFinalValue(props.value) : undefined}
/>
) : (
<input
placeholder={
props.name ? twuiSlugToNormalText(props.name) : undefined
}
{...props}
className={twMerge(
"w-full outline-none bg-transparent border-none",
"hover:border-none hover:outline-none focus:border-none focus:outline-none",
"dark:bg-transparent dark:outline-none dark:border-none",
"p-0 grow",
"twui-input",
props.className,
)}
ref={inputRef}
onFocus={(e) => {
setFocus(true);
props?.onFocus?.(e);
}}
onBlur={(e) => {
setFocus(false);
props?.onBlur?.(e);
}}
onChange={handleValueChange}
type={inputType}
defaultValue={defaultInitialValue}
autoComplete={autoComplete}
value={props.value ? getFinalValue(props.value) : undefined}
/>
);
return (
<Stack
title={`${finalLabel}${props.required ? " (Required)" : ""}`}
{...wrapperWrapperProps}
className={twMerge(
"w-full gap-1.5 relative z-0 hover:z-100",
wrapperWrapperProps?.className,
)}
>
<div
{...wrapperProps}
className={twMerge(
"relative flex items-center gap-2 rounded-default px-3 py-2 outline-1",
"hover:[&_.twui-clear-input-field-button]:opacity-100",
"w-full border-none",
focus && validity.isValid
? "outline-slate-700 dark:outline-white/50"
: "outline-slate-300 dark:outline-white/20",
focus && validity.isValid
? "outline-slate-700 dark:outline-white/50"
: "outline-slate-300 dark:outline-white/20",
variant == "warning" &&
validity.isValid &&
"outline-yellow-500 dark:outline-yellow-300",
variant == "error" &&
validity.isValid &&
"border-red-500 dark:border-red-300 outline-red-500 dark:outline-red-300",
variant == "inactive" &&
validity.isValid &&
"opacity-40 pointer-events-none",
"bg-white dark:bg-background-dark",
validity.isValid
? ""
: "border-orange-500 outline-orange-500 dark:border-orange-500 dark:outline-orange-500 twui-input-invalid",
props.readOnly
? props.type == "password"
? "opacity-50"
: "opacity-50 pointer-events-none"
: undefined,
"twui-input-wrapper",
wrapperProps?.className,
)}
>
{showLabel && (
<label
htmlFor={props.name}
{...labelProps}
className={twMerge(
"text-xs absolute -top-2.5 left-2 text-foreground-light/80 bg-background-light",
"dark:text-foreground-dark/80 dark:bg-background-dark whitespace-nowrap",
"overflow-hidden overflow-ellipsis z-20 px-1.5 rounded-t-default",
"twui-input-label",
labelProps?.className,
)}
>
{finalLabel}
{props.required ? (
<span className="text-secondary ml-1">*</span>
) : (
""
)}
</label>
)}
{prefix && prefix}
{targetComponent}
{props.type == "search" || props.readOnly ? null : (
<div
title="Clear Input Field"
{...clearInputProps}
className={twMerge(
"p-1 -my-2 -mx-1 opacity-0 cursor-pointer w-7 h-7",
"bg-background-light dark:bg-background-dark",
"twui-clear-input-field-button",
clearInputProps?.className,
)}
onClick={(e) => {
e.preventDefault();
if (inputRef.current) {
inputRef.current.value = "";
}
if (textAreaRef.current) {
textAreaRef.current.value = "";
}
setValue("");
clearInputProps?.onClick?.(e);
}}
>
<X className="w-full h-full" />
</div>
)}
{props.type == "password" ? (
<div
title={
inputType == "password"
? "View Psasword"
: "Hide Password"
}
className={twMerge("p-1 -my-2 -mx-1")}
onClick={(e) => {
e.preventDefault();
if (inputType == "password") {
setInputType("text");
} else {
setInputType("password");
}
}}
>
{inputType == "password" ? (
<Eye size={15} />
) : (
<EyeOff size={15} />
)}
</div>
) : null}
{suffix ? suffix : null}
{numberText ? (
<NumberInputButtons
setValue={setValue}
inputRef={inputRef}
getNormalizedValue={getNormalizedValue}
max={props.max}
min={props.min}
step={props.step}
buttonDownRef={buttonDownRef}
/>
) : null}
</div>
{info && (
<Dropdown
target={
<Row className="gap-1">
<Info size={12} className="opacity-40" />
<Span
size="smaller"
className="opacity-70 hover:opacity-100"
>
{info}
</Span>
</Row>
}
openDebounce={700}
className="z-1000"
hoverOpen
>
<Paper
className={twMerge(
"min-w-[250px] shadow-lg shadow-slate-200 dark:shadow-white/10",
"max-w-[300px] w-full bg-background-light! dark:bg-background-dark! z-1000",
)}
>
<Stack className="gap-2 items-center">
<Row className="gap-1">
<InfoIcon size={15} opacity={0.4} />
<Span className="text-xs opacity-50">Info</Span>
</Row>
<Span className="text-center">{info}</Span>
</Stack>
</Paper>
</Dropdown>
)}
{!validity.isValid && validity.msg ? (
<Span className="text-warning whitespace-nowrap" size="smaller">
{validity.msg || "Invalid"}
</Span>
) : undefined}
</Stack>
);
}

View File

@ -0,0 +1,79 @@
import {
ComponentProps,
DetailedHTMLProps,
InputHTMLAttributes,
LabelHTMLAttributes,
} from "react";
import Row from "../layout/Row";
import twuiSlugify from "../utils/slugify";
import twuiSlugToNormalText from "../utils/slug-to-normal-text";
import { twMerge } from "tailwind-merge";
type Value = {
value: string;
title?: string;
default?: boolean;
};
export type TWUI_FORM_RADIO_PROPS = {
values: Value[];
name: string;
inputProps?: DetailedHTMLProps<
InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
>;
labelProps?: DetailedHTMLProps<
LabelHTMLAttributes<HTMLLabelElement>,
HTMLLabelElement
>;
wrapperProps?: ComponentProps<typeof Row>;
changeHandler?: (value: string) => void;
};
/**
* # Form Radios Component
* @className twui-textarea
*/
export default function Radios({
values,
name,
inputProps,
labelProps,
wrapperProps,
changeHandler,
}: TWUI_FORM_RADIO_PROPS) {
const finalName = twuiSlugify(name);
const finalTitle = twuiSlugToNormalText(finalName);
return (
<Row
title={finalTitle}
{...wrapperProps}
className={twMerge("gap-4", wrapperProps?.className)}
>
{values.map((v, i) => {
const valueName = twuiSlugify(`${finalName}-${v.value}`);
const valueTitle = v.title || twuiSlugToNormalText(v.value);
return (
<Row key={i} className="gap-1.5">
<input
id={valueName}
type="radio"
defaultChecked={v.default}
name={finalName}
onChange={(e) => {
const targetValue = v.value;
changeHandler?.(targetValue);
}}
{...inputProps}
/>
<label htmlFor={valueName} {...labelProps}>
{valueTitle}
</label>
</Row>
);
})}
</Row>
);
}

View File

@ -0,0 +1,305 @@
import { ChevronDown, Info, LucideProps, Search } from "lucide-react";
import React from "react";
import { twMerge } from "tailwind-merge";
import Dropdown from "../elements/Dropdown";
import Stack from "../layout/Stack";
import {
TWUISelectOptionObject,
TWUISelectProps,
TWUISelectValidityObject,
} from "./Select";
import Border from "../elements/Border";
import Input from "./Input";
import Paper from "../elements/Paper";
import Button from "../layout/Button";
import Divider from "../layout/Divider";
/**
* # Search Select Element
* @className twui-search-select-wrapper
* @className twui-search-select
* @className twui-search-select-dropdown-icon
*/
export default function SearchSelect<
KeyType extends string,
T extends { [k: string]: any } = { [k: string]: any }
>({
label,
options,
componentRef,
labelProps,
wrapperProps,
showLabel,
iconProps,
changeHandler,
info,
validateValueFn,
wrapperWrapperProps,
dispatchState,
...props
}: TWUISelectProps<KeyType, T>) {
const [validity, setValidity] = React.useState<TWUISelectValidityObject>({
isValid: true,
});
const selectRef = componentRef || React.useRef<HTMLSelectElement>(null);
const [currentOptions, setCurrentOptions] =
React.useState<TWUISelectOptionObject<KeyType, T>[]>(options);
const defaultOption = (options.find((opt) => opt.default) || options[0]) as
| TWUISelectOptionObject<KeyType, T>
| undefined;
const [value, setValue] = React.useState<
TWUISelectOptionObject<KeyType, T> | undefined
>(
defaultOption
? {
value: defaultOption?.value,
data: defaultOption?.data,
}
: undefined
);
const [inputValue, setInputValue] = React.useState<string>(
defaultOption?.value || ""
);
const [selectIndex, setSelectIndex] = React.useState<number | undefined>();
const [open, setOpen] = React.useState<boolean>(false);
const isFocusedRef = React.useRef<boolean>(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const contentWrapperRef = React.useRef<HTMLDivElement>(null);
let focusTimeout: any;
let keyDownInterval: any;
const FOCUS_TIMEOUT = 200;
React.useEffect(() => {
setTimeout(() => {
requestAnimationFrame(() => {
const currentSelectValue = selectRef.current?.value;
if (currentSelectValue && validateValueFn) {
validateValueFn(currentSelectValue).then((res) => {
setValidity(res);
});
}
});
}, 200);
}, []);
React.useEffect(() => {
if (!open) {
setCurrentOptions(options);
}
}, [open]);
React.useEffect(() => {
if (value) {
dispatchState?.(value.data);
setInputValue(value.value);
changeHandler?.(value.value);
}
clearTimeout(focusTimeout);
setOpen(false);
setSelectIndex(undefined);
}, [value]);
const handleArrowUpScrollAdjust = React.useCallback(() => {
if (contentWrapperRef.current) {
const targetOption = contentWrapperRef.current.querySelector(
".twui-select-target-option"
) as HTMLButtonElement;
if (targetOption) {
contentWrapperRef.current.scrollTop =
targetOption.offsetTop - 100;
}
}
}, []);
const handleArrowDownScrollAdjust = React.useCallback(() => {
if (contentWrapperRef.current) {
const targetOption = contentWrapperRef.current.querySelector(
".twui-select-target-option"
) as HTMLButtonElement;
if (targetOption) {
contentWrapperRef.current.scrollTop =
targetOption.offsetTop -
(contentWrapperRef.current.offsetHeight - 100);
}
}
}, []);
React.useEffect(() => {
if (!selectIndex) return;
}, [selectIndex]);
const handleKey = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
if (selectIndex !== undefined) {
setValue(currentOptions[selectIndex]);
if (inputRef.current) {
inputRef.current.blur();
}
}
} else if (e.key === "ArrowUp") {
if (selectIndex == undefined) {
setSelectIndex(currentOptions.length - 1);
} else if (selectIndex === 0) {
setSelectIndex(0);
} else {
setSelectIndex(selectIndex - 1);
}
handleArrowUpScrollAdjust();
} else if (e.key === "ArrowDown") {
if (selectIndex == undefined) {
setSelectIndex(0);
} else if (selectIndex === currentOptions.length - 1) {
setSelectIndex(currentOptions.length - 1);
} else {
setSelectIndex(selectIndex + 1);
}
handleArrowDownScrollAdjust();
}
};
const handleKeyUp = (e: React.KeyboardEvent) => {
e.preventDefault();
clearInterval(keyDownInterval);
handleKey(e);
};
// const handleKeyDown = (e: React.KeyboardEvent) => {
// e.preventDefault();
// keyDownInterval = setInterval(() => {
// handleKey(e);
// }, 100);
// };
return (
<Stack
{...wrapperWrapperProps}
className={twMerge(
"gap-1 w-full",
"twui-search-select-wrapper",
wrapperWrapperProps?.className
)}
onKeyUp={handleKeyUp}
// onKeyDown={handleKeyDown}
>
<Dropdown
disableClickActions
target={
<Input
type="text"
placeholder={props.title || "Search Options"}
value={inputValue}
prefix={(<Search size={18} />) as any}
suffix={(<ChevronDown size={20} />) as any}
suffixProps={{
onClick: (e) => {
e.preventDefault();
clearTimeout(focusTimeout);
setOpen(!open);
},
className: "pointer-events-auto opacity-100",
}}
changeHandler={(value) => {
if (!isFocusedRef.current) return;
if (!open) setOpen(true);
}}
onFocus={() => {
clearTimeout(focusTimeout);
isFocusedRef.current = true;
setOpen(true);
}}
onBlur={() => {
focusTimeout = setTimeout(() => {
isFocusedRef.current = false;
setOpen(false);
}, FOCUS_TIMEOUT);
}}
onChange={(e) => {
if (!open) setOpen(true);
setInputValue(e.target.value);
const updatedOptions = options.filter((option) =>
option.value
.toLowerCase()
.match(
new RegExp(
`${e.target.value.toLowerCase()}`
)
)
);
if (updatedOptions?.[0]) {
setCurrentOptions(updatedOptions);
} else {
setCurrentOptions(options);
}
setSelectIndex(undefined);
}}
componentRef={inputRef}
showLabel={showLabel}
/>
}
targetWrapperProps={{ className: "w-full" }}
contentWrapperProps={{ className: "w-full" }}
className="w-full"
externalOpen={currentOptions?.[0] && open}
>
<Paper
className={twMerge(
"gap-0 p-0 w-full max-h-[40vh] overflow-y-auto"
)}
componentRef={contentWrapperRef}
>
<Stack className="w-full items-start gap-0">
{currentOptions?.[0]
? currentOptions.map((_o, index) => {
const isTargetOption = index === selectIndex;
const targetOptionClasses = twMerge(
"bg-background-dark dark:bg-background-light text-foreground-dark dark:text-foreground-light",
"twui-select-target-option"
);
return (
<React.Fragment key={index}>
<Button
title={_o.title || "Option"}
variant="ghost"
onClick={(e) => {
e.preventDefault();
setValue(_o);
setOpen(false);
}}
className={twMerge(
"w-full text-foreground-light dark:text-foreground-dark",
"hover:bg-gray/20 dark:hover:bg-gray-dark/20",
isTargetOption
? targetOptionClasses
: ""
)}
>
{_o.value}
</Button>
<Divider />
</React.Fragment>
);
})
: null}
</Stack>
</Paper>
</Dropdown>
</Stack>
);
}

252
components/form/Select.tsx Normal file
View File

@ -0,0 +1,252 @@
import { ChevronDown, Info, LucideProps } from "lucide-react";
import React, {
ComponentProps,
DetailedHTMLProps,
Dispatch,
InputHTMLAttributes,
LabelHTMLAttributes,
ReactNode,
RefObject,
SelectHTMLAttributes,
SetStateAction,
} from "react";
import { twMerge } from "tailwind-merge";
import Row from "../layout/Row";
import Dropdown from "../elements/Dropdown";
import Card from "../elements/Card";
import Span from "../layout/Span";
import Stack from "../layout/Stack";
import twuiSlugify from "../utils/slugify";
import twuiSlugToNormalText from "../utils/slug-to-normal-text";
export type TWUISelectValidityObject = {
isValid?: boolean;
msg?: string;
};
export type TWUISelectOptionObject<
KeyType extends string = string,
T extends { [k: string]: any } = { [k: string]: any }
> = {
title?: string;
value: KeyType;
default?: boolean;
data?: T;
};
export type TWUISelectProps<
KeyType extends string,
T extends { [k: string]: any } = { [k: string]: any }
> = DetailedHTMLProps<
SelectHTMLAttributes<HTMLSelectElement>,
HTMLSelectElement
> & {
options: TWUISelectOptionObject<KeyType, T>[];
label?: string;
showLabel?: boolean;
wrapperProps?: DetailedHTMLProps<
InputHTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
wrapperWrapperProps?: ComponentProps<typeof Stack>;
labelProps?: DetailedHTMLProps<
LabelHTMLAttributes<HTMLLabelElement>,
HTMLLabelElement
>;
componentRef?: RefObject<HTMLSelectElement>;
iconProps?: LucideProps;
changeHandler?: (value: KeyType, data?: T) => void;
info?: string | ReactNode;
validateValueFn?: (value: string) => Promise<TWUISelectValidityObject>;
dispatchState?: Dispatch<SetStateAction<T | undefined>>;
name?: KeyType;
};
export type TWUISelectValueObject<
KeyType extends string,
T extends { [k: string]: any } = { [k: string]: any }
> = {
value: KeyType;
data?: T;
};
/**
* # Select Element
* @className twui-select-wrapper
* @className twui-select
* @className twui-select-dropdown-icon
*/
export default function Select<
KeyType extends string,
T extends { [k: string]: any } = { [k: string]: any }
>({
label,
options,
componentRef,
labelProps,
wrapperProps,
showLabel,
iconProps,
changeHandler,
info,
validateValueFn,
wrapperWrapperProps,
dispatchState,
...props
}: TWUISelectProps<KeyType, T>) {
const [validity, setValidity] = React.useState<TWUISelectValidityObject>({
isValid: true,
});
const selectRef = componentRef || React.useRef<HTMLSelectElement>(null);
const [value, setValue] = React.useState<TWUISelectValueObject<KeyType, T>>(
{
value: options[0]?.value,
data: options[0]?.data,
}
);
React.useEffect(() => {
setTimeout(() => {
requestAnimationFrame(() => {
const currentSelectValue = selectRef.current?.value;
if (currentSelectValue && validateValueFn) {
validateValueFn(currentSelectValue).then((res) => {
setValidity(res);
});
}
});
}, 200);
}, []);
React.useEffect(() => {
dispatchState?.(value.data);
}, [value]);
const selectID = label
? twuiSlugify(label)
: props.name
? twuiSlugify(props.name)
: props.title
? twuiSlugify(props.title)
: `select-${Math.round(Math.random() * 1000000)}`;
return (
<Stack
{...wrapperWrapperProps}
className={twMerge("gap-1", wrapperWrapperProps?.className)}
>
<div
{...wrapperProps}
className={twMerge(
"relative w-full flex items-center border rounded-default",
"border-slate-300 dark:border-white/20 pr-2",
"focus:border-slate-700 dark:focus:border-white/50",
"outline-slate-300 dark:outline-white/20",
"focus:outline-slate-700 dark:focus:outline-white/50",
"bg-white dark:bg-background-dark",
validity.isValid ? "" : "outline-warning border-warning",
wrapperProps?.className
)}
>
{showLabel && (
<label
htmlFor={selectID}
{...labelProps}
className={twMerge(
"text-xs absolute -top-2.5 left-2 text-foreground-light/80 bg-background-light",
"dark:text-foreground-dark/70 dark:bg-background-dark px-1.5 rounded-t",
"twui-input-label",
labelProps?.className
)}
>
{label || props.title || props.name}
</label>
)}
<select
id={selectID}
aria-label={props["aria-label"] || props.title}
{...props}
className={twMerge(
"w-full pl-3 py-2 rounded-default appearance-none pr-8",
"grow !border-none !outline-none",
"twui-select",
props.className
)}
ref={selectRef}
value={
options.flat().find((opt) => opt.default)?.value ||
undefined
}
onChange={(e) => {
const targetValue = options.find(
(opt) => opt.value == e.target.value
);
if (targetValue) {
setValue(targetValue);
}
changeHandler?.(
e.target.value as (typeof options)[number]["value"],
targetValue?.data
);
props.onChange?.(e);
validateValueFn?.(e.target.value).then((res) => {
setValidity(res);
});
}}
>
{options.flat().map((option, index) => {
const optionTitle =
option.title || twuiSlugToNormalText(option.value);
return (
<option key={index} value={option.value}>
{optionTitle}
</option>
);
})}
</select>
<ChevronDown
size={20}
{...iconProps}
className={twMerge(
"pointer-events-none -ml-6",
iconProps?.className
)}
/>
{info && (
<Dropdown
target={
<div title="Select Info Button">
<Info size={20} />
</div>
}
hoverOpen
>
<Card className="min-w-[250px] p-6">
{typeof info == "string" ? (
<Span>{info}</Span>
) : (
info
)}
</Card>
</Dropdown>
)}
</div>
{!validity.isValid && validity.msg ? (
<Span size="smaller" className="text-warning">
{validity.msg}
</Span>
) : undefined}
</Stack>
);
}

View File

@ -1,24 +1,12 @@
import { DetailedHTMLProps, TextareaHTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
import Input, { InputProps } from "./Input";
/**
* # Textarea Component
* @className twui-textarea
*/
export default function Textarea({
export default function Textarea<KeyType extends string>({
componentRef,
...props
}: DetailedHTMLProps<
TextareaHTMLAttributes<HTMLTextAreaElement>,
HTMLTextAreaElement
>) {
return (
<textarea
{...props}
className={twMerge(
"w-full px-4 py-2 border border-slate-300 rounded",
"twui-textarea",
props.className
)}
/>
);
}: InputProps<KeyType>) {
return <Input istextarea {...props} componentRef={componentRef} />;
}

View File

@ -0,0 +1,37 @@
import React from "react";
type Param = {
/**
* Custom Event Name
*/
name: string;
};
/**
* # Dispatch Custom Event
*/
export default function useCustomEventDispatch<
T extends { [key: string]: any } = { [key: string]: any }
>({ name }: Param) {
const dispatchCustomEvent = React.useCallback((value: T | string) => {
let dataParsed = typeof value == "object" ? value : undefined;
const str = typeof value == "string" ? value : undefined;
if (str) {
try {
dataParsed = JSON.parse(str);
} catch (error) {}
}
const event = new CustomEvent(name, {
detail: {
data: dataParsed,
message: str,
},
});
window.dispatchEvent(event);
}, []);
return { dispatchCustomEvent };
}

View File

@ -0,0 +1,39 @@
import React from "react";
type Param = {
/**
* Custom Event Name
*/
name: string;
};
/**
* # Listen For Custom Event
*/
export default function useCustomEventListener<
T extends { [key: string]: any } = { [key: string]: any }
>({ name }: Param) {
const [data, setData] = React.useState<T | undefined>(undefined);
const [message, setMessage] = React.useState<string | undefined>(undefined);
const dataEventListenerCallback = React.useCallback((e: Event) => {
const customEvent = e as CustomEvent;
const eventPayloadMessage = customEvent.detail.message as
| string
| undefined;
const eventPayloadData = customEvent.detail.data as T | undefined;
if (eventPayloadMessage) setMessage(eventPayloadMessage);
if (eventPayloadData) setData(eventPayloadData);
}, []);
React.useEffect(() => {
window.addEventListener(name, dataEventListenerCallback, false);
return function () {
window.removeEventListener(name, dataEventListenerCallback);
};
}, []);
return { data, message };
}

View File

@ -0,0 +1,79 @@
import React from "react";
type Param = {
elementRef?: React.RefObject<Element | undefined>;
className?: string;
elId?: string;
options?: IntersectionObserverInit;
removeIntersected?: boolean;
delay?: number;
};
let timeout: any;
export default function useIntersectionObserver({
elementRef,
className,
options,
removeIntersected,
delay,
elId,
}: Param) {
const [isIntersecting, setIsIntersecting] = React.useState(false);
const [refresh, setRefresh] = React.useState(0);
const observerTriggerDelay = delay || 200;
const observerCallback: IntersectionObserverCallback = React.useCallback(
(entries, observer) => {
const entry = entries[0];
window.clearTimeout(timeout);
if (entry.isIntersecting) {
timeout = setTimeout(() => {
setIsIntersecting(true);
if (removeIntersected) {
observer.unobserve(entry.target);
}
}, observerTriggerDelay);
} else {
setIsIntersecting(false);
}
},
[]
);
React.useEffect(() => {
const element = elId
? document.getElementById(elId)
: elementRef?.current;
const elements = className
? document.querySelectorAll(`.${className}`)
: null;
if (!element && !className && refresh < 5) {
requestAnimationFrame(() => {
setTimeout(() => {
setRefresh(refresh + 1);
}, 2000);
});
return;
}
const observer = new IntersectionObserver(observerCallback, {
rootMargin: "0px 0px 0px 0px",
...options,
});
if (elements) {
elements.forEach((el) => {
observer.observe(el);
});
} else if (element) {
observer.observe(element);
}
}, [refresh]);
return { isIntersecting };
}

View File

@ -0,0 +1,31 @@
import React from "react";
export type UseLocalStorageParam<
T extends { [key: string]: any } = { [key: string]: any }
> = {
key: keyof T;
};
/**
* # Use Local Storage
*/
export default function useLocalStorage<
T extends Record<string, any> | undefined = undefined
>(param?: UseLocalStorageParam) {
const [data, setData] =
React.useState<T extends undefined ? string | null : T>();
React.useEffect(() => {
if (param?.key) {
const value = localStorage.getItem(param.key as string);
try {
const jsonValue = JSON.parse(value || "");
setData(jsonValue as any);
} catch (error) {
setData(value as any);
}
}
}, []);
return { data };
}

View File

@ -0,0 +1,29 @@
import React from "react";
type Params = {
timeout?: number;
};
let timeout: any;
export default function useReady(params?: Params) {
const [ready, setReady] = React.useState(false);
const finalTimeout = params?.timeout || 300;
React.useEffect(() => {
requestAnimationFrame(() => {
window.clearTimeout(timeout);
timeout = setTimeout(() => {
setReady(true);
}, finalTimeout);
});
return function () {
window.clearTimeout(timeout);
};
}, []);
return { ready };
}

View File

@ -0,0 +1,35 @@
import React from "react";
type Params = {
initialLoading?: boolean;
initialReady?: boolean;
initialOpen?: boolean;
};
export type UseStatusStatusType = {
msg?: string;
error?: boolean;
};
export default function useStatus(params?: Params) {
const [refresh, setRefresh] = React.useState(0);
const [loading, setLoading] = React.useState(
params?.initialLoading || false,
);
const [status, setStatus] = React.useState<UseStatusStatusType>({});
const [ready, setReady] = React.useState(params?.initialReady || false);
const [open, setOpen] = React.useState(params?.initialOpen || false);
return {
refresh,
setRefresh,
loading,
setLoading,
status,
setStatus,
ready,
setReady,
open,
setOpen,
};
}

View File

@ -0,0 +1,209 @@
import React, { useRef } from "react";
export type UseWebsocketHookParams = {
debounce?: number;
url: string;
disableReconnect?: boolean;
/** Interval to ping the websocket. So that the connection doesn't go down. Default 30000ms (30 seconds) */
keepAliveDuration?: number;
refreshConnection?: number;
};
export const WebSocketEventNames = ["wsDataEvent", "wsMessageEvent"] as const;
/**
* # Use Websocket Hook
* @event wsDataEvent Listen for event named `wsDataEvent` on `window` to receive Data events
* @event wsMessageEvent Listen for event named `wsMessageEvent` on `window` to receive Message events
*
* @example window.addEventLiatener("wsDataEvent", (e)=>{
* console.log(e.detail.data) // type object
* })
* @example window.addEventLiatener("wsMessageEvent", (e)=>{
* console.log(e.detail.message) // type string
* })
*/
export default function useWebSocket<
T extends { [key: string]: any } = { [key: string]: any }
>({
url,
debounce,
disableReconnect,
keepAliveDuration,
refreshConnection,
}: UseWebsocketHookParams) {
const DEBOUNCE = debounce || 500;
const KEEP_ALIVE_DURATION = keepAliveDuration || 1000 * 30;
const KEEP_ALIVE_TIMEOUT = 1000 * 60 * 3;
const KEEP_ALIVE_MESSAGE = "twui::ping";
let uptime = 0;
let tries = useRef(0);
// const queue: string[] = [];
const msgInterval = useRef<any>(null);
const sendInterval = useRef<any>(null);
const keepAliveInterval = useRef<any>(null);
const [socket, setSocket] = React.useState<WebSocket | undefined>(
undefined
);
const messageQueueRef = React.useRef<string[]>([]);
const sendMessageQueueRef = React.useRef<string[]>([]);
/**
* # Dispatch Custom Event
*/
const dispatchCustomEvent = React.useCallback(
(evtName: (typeof WebSocketEventNames)[number], value: string | T) => {
const event = new CustomEvent(evtName, {
detail: {
data: value,
message: value,
},
});
window.dispatchEvent(event);
},
[]
);
/**
* # Connect to Websocket
*/
const connect = React.useCallback(() => {
const domain = window.location.origin;
const wsURL = url.startsWith(`ws`)
? url
: domain.replace(/^http/, "ws") + ("/" + url).replace(/\/\//g, "/");
if (!wsURL) return;
let ws = new WebSocket(wsURL);
ws.onerror = (ev) => {
console.log(`Websocket ERROR:`);
};
ws.onmessage = (ev) => {
messageQueueRef.current.push(ev.data);
};
ws.onopen = (ev) => {
window.clearInterval(keepAliveInterval.current);
keepAliveInterval.current = window.setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(KEEP_ALIVE_MESSAGE);
}
}, KEEP_ALIVE_DURATION);
setSocket(ws);
console.log(`Websocket connected to ${wsURL}`);
};
ws.onclose = (ev) => {
console.log("Websocket closed!", {
code: ev.code,
reason: ev.reason,
wasClean: ev.wasClean,
});
if (disableReconnect) return;
console.log("Attempting to reconnect ...");
console.log("URL:", url);
window.clearInterval(keepAliveInterval.current);
console.log("tries", tries);
if (tries.current >= 3) {
return;
}
console.log("Attempting to reconnect ...");
tries.current += 1;
connect();
};
}, []);
/**
* # Initial Connection
*/
React.useEffect(() => {
if (socket) return;
connect();
}, []);
React.useEffect(() => {
if (!socket) return;
sendInterval.current = setInterval(handleSendMessageQueue, DEBOUNCE);
msgInterval.current = setInterval(handleReceivedMessageQueue, DEBOUNCE);
return function () {
window.clearInterval(sendInterval.current);
window.clearInterval(msgInterval.current);
};
}, [socket]);
/**
* Received Message Queue Handler
*/
const handleReceivedMessageQueue = React.useCallback(() => {
try {
const msg = messageQueueRef.current.shift();
if (!msg) return;
const jsonData = JSON.parse(msg);
dispatchCustomEvent("wsMessageEvent", msg);
dispatchCustomEvent("wsDataEvent", jsonData);
} catch (error) {
console.log("Unable to parse string. Returning string.");
}
}, []);
/**
* Send Message Queue Handler
*/
const handleSendMessageQueue = React.useCallback(() => {
if (!socket || socket.readyState !== WebSocket.OPEN) {
window.clearInterval(sendInterval.current);
return;
}
const newMessage = sendMessageQueueRef.current.shift();
if (!newMessage) return;
socket.send(newMessage);
}, [socket]);
/**
* # Send Data Function
*/
const sendData = React.useCallback(
(data: T) => {
try {
const queueItemJSON = JSON.stringify(data);
const existingQueue = sendMessageQueueRef.current.find(
(q) => q == queueItemJSON
);
if (!existingQueue) {
sendMessageQueueRef.current.push(queueItemJSON);
}
} catch (error: any) {
console.log("Error Sending socket message", error.message);
}
},
[socket]
);
return { socket, sendData };
}

View File

@ -0,0 +1,40 @@
import React from "react";
import { WebSocketEventNames } from "./useWebSocket";
type Param = {
listener?: (typeof WebSocketEventNames)[number];
};
/**
* # Use Websocket Data Event Handler Hook
*/
export default function useWebSocketEventHandler<
T extends { [key: string]: any } = { [key: string]: any }
>(param?: Param) {
const [data, setData] = React.useState<T | undefined>(undefined);
const [message, setMessage] = React.useState<string | undefined>(undefined);
React.useEffect(() => {
const dataEventListenerCallback = (e: Event) => {
const customEvent = e as CustomEvent;
const data = customEvent.detail.data as T | undefined;
const __msg = customEvent.detail.message as string | undefined;
if (data) setData(data);
if (__msg && typeof __msg == "string") setMessage(__msg);
};
const messageEventName: (typeof WebSocketEventNames)[number] =
param?.listener || "wsDataEvent";
window.addEventListener(messageEventName, dataEventListenerCallback);
return function () {
window.removeEventListener(
messageEventName,
dataEventListenerCallback
);
};
}, []);
return { data, message };
}

View File

@ -0,0 +1,24 @@
import { useCallback, useEffect, useState } from "react";
export default function useWindowFocus() {
const [isWindowFocused, setIsWindowFocused] = useState(false);
const windowFocusCb = useCallback(() => {
setIsWindowFocused(true);
}, []);
const windowBlurCb = useCallback(() => {
setIsWindowFocused(false);
}, []);
useEffect(() => {
window.addEventListener("focus", windowFocusCb);
window.addEventListener("blur", windowBlurCb);
return function () {
window.removeEventListener("focus", windowFocusCb);
window.removeEventListener("blur", windowBlurCb);
};
}, []);
return { isWindowFocused };
}

View File

@ -0,0 +1,25 @@
import { ComponentProps, ReactNode } from "react";
import { ArrowUpRight } from "lucide-react";
import { TWUI_LINK_LIST_LINK_OBJECT } from "../elements/LinkList";
import Link from "./Link";
import Row from "./Row";
type Props = ComponentProps<typeof Link> & {
link: TWUI_LINK_LIST_LINK_OBJECT;
icon?: ReactNode;
};
/**
* # Link With an Arrow
* @className twui-arrowed-link
*/
export default function ArrowedLink({ link, icon, ...props }: Props) {
return (
<Link href={link.url} {...props} {...link.linkProps}>
<Row>
<span>{link.title}</span>
{icon || <ArrowUpRight size={17} />}
</Row>
</Link>
);
}

View File

@ -1,11 +1,48 @@
import {
import React, {
AnchorHTMLAttributes,
ButtonHTMLAttributes,
ComponentProps,
DetailedHTMLProps,
HTMLAttributeAnchorTarget,
HTMLAttributes,
} from "react";
import { twMerge } from "tailwind-merge";
import Loading from "../elements/Loading";
import LucideIcon, { TWUILucideIconName } from "../elements/lucide-icon";
export type TWUIButtonProps = DetailedHTMLProps<
ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
> & {
title: string;
variant?: "normal" | "ghost" | "outlined";
color?:
| "primary"
| "secondary"
| "text"
| "white"
| "accent"
| "gray"
| "error"
| "warning"
| "success";
size?: "small" | "smaller" | "normal" | "large" | "larger";
loadingIconSize?: React.ComponentProps<typeof Loading>["size"];
href?: string;
target?: HTMLAttributeAnchorTarget;
loading?: boolean;
linkProps?: DetailedHTMLProps<
AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
>;
beforeIcon?: TWUILucideIconName | React.JSX.Element;
afterIcon?: TWUILucideIconName | React.JSX.Element;
buttonContentProps?: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
loadingProps?: ComponentProps<typeof Loading>;
};
/**
* # Buttons
@ -17,106 +54,196 @@ import Loading from "../elements/Loading";
* @className twui-button-secondary
* @className twui-button-secondary-outlined
* @className twui-button-secondary-ghost
* @className twui-button-white
* @className twui-button-white-outlined
* @className twui-button-white-ghost
* @className twui-button-accent
* @className twui-button-accent-outlined
* @className twui-button-accent-ghost
* @className twui-button-gray
* @className twui-button-gray-outlined
* @className twui-button-gray-ghost
*
* @example
```css
CSS directive:
//@theme inline {
--breakpoint-xs: 350px;
--color-primary: #000000;
--color-primary-hover: #29292b;
--color-primary-outline: #29292b;
--color-primary-text: #29292b;
--color-primary-dark: #29292b;
--color-primary-dark-hover: #4b4b4b;
--color-primary-dark-outline: #4b4b4b;
--color-primary-dark-text: #4b4b4b;
--color-secondary: #000000;
--color-secondary-hover: #dddddd;
--color-secondary-outline: #dddddd;
--color-secondary-text: #dddddd;
--color-secondary-dark: #000000;
--color-secondary-dark-hover: #dddddd;
--color-secondary-dark-outline: #dddddd;
--color-secondary-dark-text: #dddddd;
--color-accent: #000000;
--color-accent-hover: #dddddd;
--color-accent-outline: #dddddd;
--color-accent-text: #dddddd;
--color-accent-dark: #000000;
--color-accent-dark-hover: #dddddd;
--color-accent-dark-outline: #dddddd;
--color-accent-dark-text: #dddddd;
}
```
*/
export default function Button({
href,
target,
variant,
color,
size,
buttonContentProps,
linkProps,
beforeIcon,
afterIcon,
loading,
loadingIconSize,
loadingProps,
...props
}: DetailedHTMLProps<
ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
> & {
variant?: "normal" | "ghost" | "outlined";
color?: "primary" | "secondary" | "accent" | "gray";
href?: string;
target?: HTMLAttributeAnchorTarget;
loading?: boolean;
linkProps?: DetailedHTMLProps<
AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
>;
}) {
}: TWUIButtonProps) {
const finalClassName: string = (() => {
if (variant == "normal" || !variant) {
if (color == "primary" || !color)
return twMerge(
"bg-blue-500 hover:bg-blue-600 text-white",
"twui-button-primary"
"bg-primary hover:bg-primary-hover text-white",
"dark:bg-primary-dark hover:dark:bg-primary-dark-hover text-white",
"twui-button-primary",
);
if (color == "secondary")
return twMerge(
"bg-emerald-500 hover:bg-emerald-600 text-white",
"twui-button-secondary"
"bg-secondary hover:bg-secondary-hover text-white",
"twui-button-secondary",
);
if (color == "white")
return twMerge(
"!bg-white hover:!bg-slate-200 !text-slate-800",
"twui-button-white",
);
if (color == "accent")
return twMerge(
"bg-violet-500 hover:bg-violet-600 text-white",
"twui-button-accent"
"bg-accent hover:bg-accent-hover text-white",
"twui-button-accent",
);
if (color == "gray")
return twMerge(
"bg-slate-300 hover:bg-slate-200 text-slate-800",
"twui-button-gray"
"bg-gray hover:bg-gray-hover text-foreground-light",
"dark:bg-gray-dark hover:dark:bg-gray-dark-hover dark:text-foreground-dark",
"twui-button-gray",
);
if (color == "success")
return twMerge(
"bg-success hover:bg-success-hover text-white",
"dark:bg-success hover:dark:bg-success-hover text-white",
"twui-button-success",
);
if (color == "error")
return twMerge(
"bg-error hover:bg-error-hover text-white",
"dark:bg-error hover:dark:bg-error-hover text-white",
"twui-button-error",
);
} else if (variant == "outlined") {
if (color == "primary" || !color)
return twMerge(
"bg-transparent outline outline-1 outline-blue-500",
"text-blue-500",
"twui-button-primary-outlined"
"bg-transparent outline outline-1 outline-primary",
"text-primary-text dark:text-primary-dark-text dark:outline-primary-dark-outline",
"twui-button-primary-outlined",
);
if (color == "secondary")
return twMerge(
"bg-transparent outline outline-1 outline-emerald-500",
"text-emerald-500",
"twui-button-secondary-outlined"
"bg-transparent outline outline-1 outline-secondary",
"text-secondary",
"twui-button-secondary-outlined",
);
if (color == "accent")
return twMerge(
"bg-transparent outline outline-1 outline-violet-500",
"text-violet-500",
"twui-button-accent-outlined"
"bg-transparent outline outline-1 outline-accent",
"text-accent",
"twui-button-accent-outlined",
);
if (color == "gray")
return twMerge(
"bg-transparent outline outline-1 outline-slate-300",
"text-slate-600",
"twui-button-gray-outlined"
"text-slate-600 dark:text-white/60 dark:outline-white/30",
"twui-button-gray-outlined",
);
if (color == "white")
return twMerge(
"bg-transparent outline outline-1 outline-white/50",
"text-white",
"twui-button-white-outlined",
);
if (color == "error")
return twMerge(
"bg-transparent outline outline-1 outline-error text-error",
"dark:outline-error dark:text-error-dark",
"twui-button-error-outlined",
);
} else if (variant == "ghost") {
if (color == "primary" || !color)
return twMerge(
"bg-transparent outline-none p-2",
"text-blue-500",
"twui-button-primary-ghost"
"bg-transparent dark:bg-transparent outline-none p-2",
"text-primary-text dark:text-primary-dark-text hover:bg-transparent dark:hover:bg-transparent",
"twui-button-primary-ghost",
);
if (color == "secondary")
return twMerge(
"bg-transparent outline-none p-2",
"text-emerald-500",
"twui-button-secondary-ghost"
"bg-transparent dark:bg-transparent outline-none p-2",
"text-secondary hover:bg-transparent dark:hover:bg-transparent",
"twui-button-secondary-ghost",
);
if (color == "text")
return twMerge(
"bg-transparent dark:bg-transparent outline-none p-2 dark:text-foreground-dark",
"text-foreground-light hover:bg-transparent dark:hover:bg-transparent",
"twui-button-secondary-ghost",
);
if (color == "accent")
return twMerge(
"bg-transparent outline-none p-2",
"text-violet-500",
"twui-button-accent-ghost"
"bg-transparent dark:bg-transparent outline-none p-2",
"text-accent hover:bg-transparent dark:hover:bg-transparent",
"twui-button-accent-ghost",
);
if (color == "gray")
return twMerge(
"bg-transparent dark:bg-transparent outline-none p-2 hover:bg-transparent dark:hover:bg-transparent",
"text-slate-600 dark:text-white/70 hover:opacity-80",
"twui-button-gray-ghost",
);
if (color == "error")
return twMerge(
"bg-transparent outline-none p-2",
"text-slate-600",
"twui-button-gray-ghost"
"text-red-600 dark:text-red-400",
"twui-button-error-ghost",
);
if (color == "warning")
return twMerge(
"bg-transparent outline-none p-2",
"text-yellow-600",
"twui-button-warning-ghost",
);
if (color == "success")
return twMerge(
"bg-transparent outline-none p-2",
"text-success",
"twui-button-success-ghost",
);
if (color == "white")
return twMerge(
"bg-transparent outline-none p-2",
"text-white",
"twui-button-white-ghost",
);
}
@ -127,30 +254,81 @@ export default function Button({
<button
{...props}
className={twMerge(
"bg-blue-600 text-white text-base font-medium px-4 py-2 rounded",
"flex items-center justify-center relative transition-all",
"bg-primary text-white font-medium px-4 py-2 rounded-default",
"flex items-center justify-center relative transition-all cursor-pointer",
props.disabled ? "opacity-40 cursor-not-allowed" : "",
"twui-button-general",
size == "small"
? "px-3 py-1.5 twui-button-small text-sm"
: size == "smaller"
? "px-2 py-1 text-xs twui-button-smaller"
: size == "large"
? "text-lg twui-button-large"
: size == "larger"
? "px-5 py-3 text-xl twui-button-larger"
: "twui-button-base",
finalClassName,
loading ? "pointer-events-none opacity-80" : "",
props.className,
loading ? "pointer-events-none opacity-80" : "l"
)}
aria-label={props.title}
>
<div
{...buttonContentProps}
className={twMerge(
"flex items-center gap-2",
"flex flex-row items-center gap-2 whitespace-nowrap",
loading ? "opacity-0" : "",
"twui-button-content-wrapper"
"twui-button-content-wrapper",
buttonContentProps?.className,
)}
>
{beforeIcon ? (
typeof beforeIcon == "string" ? (
<LucideIcon name={beforeIcon as TWUILucideIconName} />
) : (
beforeIcon
)
) : null}
{props.children}
{afterIcon ? (
typeof afterIcon == "string" ? (
<LucideIcon name={afterIcon as TWUILucideIconName} />
) : (
afterIcon
)
) : null}
</div>
{loading && <Loading className="absolute" />}
{loading && (
<Loading
size={(() => {
if (loadingIconSize) return loadingIconSize;
switch (size) {
case "small":
return "small";
case "smaller":
return "smaller";
default:
return "normal";
}
})()}
{...loadingProps}
className={twMerge("absolute", loadingProps?.className)}
/>
)}
</button>
);
if (href)
return (
<a {...linkProps} href={href} target={target}>
<a
{...linkProps}
href={href}
target={target}
title={props.title}
aria-label={props.title}
>
{buttonComponent}
</a>
);

View File

@ -0,0 +1,23 @@
import { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
/**
* # Flexbox Column
* @className twui-center
*/
export default function Center({
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) {
return (
<div
{...props}
className={twMerge(
"flex flex-col items-center justify-center gap-4 p-2 w-full",
"h-full twui-center",
props.className
)}
>
{props.children}
</div>
);
}

View File

@ -12,8 +12,8 @@ export default function Container({
<div
{...props}
className={twMerge(
"flex items-center w-full max-w-[1200px] gap-4 justify-between",
"flex-wrap flex-col xl:flex-row",
"flex w-full max-w-container gap-4 justify-between",
"flex-wrap flex-col xl:flex-row items-start xl:items-center",
"twui-container",
props.className
)}

View File

@ -1,16 +1,21 @@
import { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
type Props = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
vertical?: boolean;
dashed?: boolean;
};
/**
* # Vertical and Horizontal Divider
* @className twui-divider
* @className twui-divider-horizontal
* @className twui-divider-vertical
*/
export default function Divider({
vertical,
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
vertical?: boolean;
}) {
export default function Divider({ vertical, dashed, ...props }: Props) {
return (
<div
{...props}
@ -20,6 +25,8 @@ export default function Divider({
? "border-0 border-l h-full min-h-5"
: "border-0 border-t w-full",
"twui-divider",
vertical ? "twui-divider-vertical" : "twui-divider-horizontal",
dashed ? "border-dashed" : "border-solid",
props.className
)}
/>

View File

@ -0,0 +1,17 @@
import { twMerge } from "tailwind-merge";
import Img, { TWUIImageProps } from "./Img";
import Border from "../elements/Border";
export default function DocsImg({ ...props }: TWUIImageProps) {
return (
<Border className={twMerge("p-0 mb-10")}>
<Img
{...props}
className={twMerge(
"w-full h-auto rounded-default overflow-hidden",
props.className
)}
/>
</Border>
);
}

View File

@ -11,7 +11,12 @@ export default function H1({
return (
<h1
{...props}
className={twMerge("text-5xl mb-4", "twui-h1", props.className)}
className={twMerge(
"text-4xl md:text-5xl mb-4",
"twui-headings twui-heading",
"twui-h1",
props.className
)}
>
{props.children}
</h1>

View File

@ -11,7 +11,12 @@ export default function H2({
return (
<h2
{...props}
className={twMerge("text-3xl mb-4", "twui-h2", props.className)}
className={twMerge(
"text-2xl md:text-3xl mb-4",
"twui-headings twui-heading",
"twui-h2",
props.className
)}
>
{props.children}
</h2>

View File

@ -11,7 +11,12 @@ export default function H3({
return (
<h3
{...props}
className={twMerge("text-xl mb-4", "twui-h3", props.className)}
className={twMerge(
"text-xl mb-4",
"twui-headings twui-heading",
"twui-h3",
props.className
)}
>
{props.children}
</h3>

View File

@ -11,7 +11,12 @@ export default function H4({
return (
<h4
{...props}
className={twMerge("text-base mb-4", "twui-h4", props.className)}
className={twMerge(
"text-base mb-4",
"twui-headings twui-heading",
"twui-h4",
props.className
)}
>
{props.children}
</h4>

View File

@ -11,7 +11,12 @@ export default function H5({
return (
<h5
{...props}
className={twMerge("text-sm mb-4", "twui-h5", props.className)}
className={twMerge(
"mb-4",
"twui-headings twui-heading",
"twui-h5",
props.className
)}
>
{props.children}
</h5>

View File

@ -12,7 +12,7 @@ export default function HR({
<hr
{...props}
className={twMerge(
"border-slate-200 dark:border-white/20 w-full my-4",
"border-slate-200 dark:border-white/10 w-full my-4",
"twui-hr",
props.className
)}

View File

@ -0,0 +1,38 @@
import { ComponentProps, ReactNode } from "react";
import { TWUI_LINK_LIST_LINK_OBJECT } from "../elements/LinkList";
import Link from "./Link";
import Row from "./Row";
type Props = ComponentProps<typeof Link> & {
link: TWUI_LINK_LIST_LINK_OBJECT;
icon: ReactNode;
iconPosition?: "before" | "after";
};
/**
* # Link With an Icon
* @className twui-arrowed-link
*/
export default function IconLink({
link,
iconPosition,
icon,
...props
}: Props) {
return (
<Link
href={link.url}
{...props}
onClick={(e) => {
link.onClick?.(e);
props.onClick?.(e);
}}
>
<Row>
{iconPosition == "before" || !iconPosition ? icon : null}
<span>{link.title}</span>
{iconPosition == "after" ? icon : null}
</Row>
</Link>
);
}

138
components/layout/Img.tsx Normal file
View File

@ -0,0 +1,138 @@
import _ from "lodash";
import React, { DetailedHTMLProps, ImgHTMLAttributes, ReactNode } from "react";
import { twMerge } from "tailwind-merge";
export type TWUIImageProps = DetailedHTMLProps<
ImgHTMLAttributes<HTMLImageElement>,
HTMLImageElement
> & {
alt: string;
size?: number;
circle?: boolean;
bgImg?: boolean;
backgroundImage?: boolean;
fallbackImageSrc?: string;
srcLight?: string;
srcDark?: string;
imgErrSrc?: string;
imgErrComp?: ReactNode;
imgErrSrcLight?: string;
imgErrSrcDark?: string;
};
/**
* # Image Component
* @className twui-img
*/
export default function Img({
imgErrSrc,
imgErrComp,
imgErrSrcDark,
imgErrSrcLight,
...props
}: TWUIImageProps) {
const width = props.size || props.width;
const height = props.size || props.height;
const sizeRatio = width && height ? Number(width) / Number(height) : 1;
const [imageError, setImageError] = React.useState(false);
const finalProps = _.omit(props, [
"size",
"circle",
"bgImg",
"backgroundImage",
"fallbackImageSrc",
"srcLight",
"srcDark",
]);
const interpolatedProps: typeof props = {
...finalProps,
width: width,
height: height,
className: twMerge(
"object-cover",
props.circle && "rounded-full",
props.bgImg || props.backgroundImage
? "absolute top-0 left-0 w-full h-full object-cover z-0"
: "",
"twui-img",
props.className
),
onError: (e) => {
if (props.fallbackImageSrc) {
e.currentTarget.src = props.fallbackImageSrc;
}
props.onError?.(e);
},
style: {
...(props.size
? {
width: `${props.size}px`,
minWidth: `${props.size}px`,
height: `${props.size}px`,
}
: {}),
...props.style,
},
};
if (imageError) {
return (
imgErrComp || (
<img
loading="lazy"
{...interpolatedProps}
src={
imgErrSrc ||
"https://static.datasquirel.com/images/user-images/user-2/castcord-image-preset_thumbnail.jpg"
}
/>
)
);
}
if (props.srcDark && props.srcLight) {
return (
<React.Fragment>
<img
loading="lazy"
{...interpolatedProps}
className={twMerge(
"hidden dark:block",
interpolatedProps.className
)}
src={props.srcDark}
onError={(e) => {
setImageError(true);
props.onError?.(e);
}}
/>
<img
loading="lazy"
{...interpolatedProps}
className={twMerge(
"block dark:hidden",
interpolatedProps.className
)}
src={props.srcLight}
onError={(e) => {
setImageError(true);
props.onError?.(e);
}}
/>
</React.Fragment>
);
}
return (
<img
{...interpolatedProps}
onError={(e) => {
setImageError(true);
props.onError?.(e);
}}
/>
);
}

View File

@ -1,29 +1,61 @@
import { AnchorHTMLAttributes, DetailedHTMLProps } from "react";
import { AnchorHTMLAttributes, DetailedHTMLProps, RefAttributes } from "react";
import { twMerge } from "tailwind-merge";
import { ArrowUpRight, LucideProps } from "lucide-react";
type Props = DetailedHTMLProps<
AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
> & {
showArrow?: boolean;
arrowSize?: number;
arrowProps?: Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>;
strict?: boolean;
};
/**
* # General Anchor Elements
* @className twui-a | twui-anchor
* @info use `cancel-link` class name to prevent triggering this link from a child element
*/
export default function Link({
showArrow,
arrowSize = 20,
arrowProps,
strict,
...props
}: DetailedHTMLProps<
AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
>) {
}: Props) {
return (
<a
{...props}
className={twMerge(
"text-base text-link-500 no-underline hover:text-link-500/50",
"text-blue-600 dark:text-blue-400",
"border-0 border-b border-blue-300 border-solid leading-4",
"text-link-500 no-underline hover:text-link-500/50",
"text-link dark:text-link-dark hover:opacity-80 transition-all",
"border-0 border-b border-link dark:border-link-dark border-solid leading-4",
"twui-anchor",
"twui-a",
props.className
)}
onClick={(e) => {
const target = e.target as HTMLElement;
if (target.closest(".cancel-link")) {
e.preventDefault();
}
props?.onClick?.(e);
}}
data-strict={strict ? "yes" : undefined}
>
{props.children}
{showArrow && (
<ArrowUpRight
size={arrowSize}
{...arrowProps}
className={twMerge(
"inline-block ml-1 -mt-[1px]",
arrowProps?.className
)}
/>
)}
</a>
);
}

View File

@ -0,0 +1,30 @@
import { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
export type LoadingRectangleBlockProps = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
/**
* # A loading Rectangle block
* @className twui-loading-rectangle-block
* @className twui-loading-block
*/
export default function LoadingRectangleBlock({
...props
}: LoadingRectangleBlockProps) {
return (
<div
{...props}
className={twMerge(
"flex items-center w-full h-10 animate-pulse bg-slate-200 rounded",
"dark:bg-slate-800",
"twui-loading-rectangle-block twui-loading-block",
props.className,
)}
>
{props.children}
</div>
);
}

View File

@ -1,18 +1,24 @@
import { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
type Props = DetailedHTMLProps<
HTMLAttributes<HTMLHeadingElement>,
HTMLHeadingElement
> & {
noMargin?: boolean;
};
/**
* # Paragraph Tag
* @className twui-p | twui-paragraph
*/
export default function P({
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>) {
export default function P({ noMargin, ...props }: Props) {
return (
<p
{...props}
className={twMerge(
"text-base py-4",
"py-4",
noMargin ? "!m-0 p-0" : "",
"twui-p",
"twui-paragraph",
props.className

View File

@ -1,18 +1,26 @@
import { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
type Props = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
noWrap?: boolean;
itemsStart?: boolean;
};
/**
* # Flexbox Row
* @className twui-row
*/
export default function Row({
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) {
export default function Row({ noWrap, itemsStart, ...props }: Props) {
return (
<div
{...props}
className={twMerge(
"flex flex-row items-center gap-2 flex-wrap",
"flex flex-row gap-2",
noWrap ? "xl:flex-nowrap" : "flex-wrap",
itemsStart ? "items-start" : "items-center",
"twui-row",
props.className
)}

View File

@ -0,0 +1,30 @@
import _ from "lodash";
import { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
type Props = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
horizontal?: boolean;
};
/**
* # Space Component
* @className twui-spacer
*/
export default function Spacer({ horizontal, ...props }: Props) {
return (
<div
{...props}
className={twMerge(
"",
horizontal ? "w-10" : "w-full h-10",
"twui-spacer",
props.className
)}
>
{props.children}
</div>
);
}

View File

@ -6,12 +6,29 @@ import { twMerge } from "tailwind-merge";
* @className twui-span
*/
export default function Span({
size,
variant,
truncate,
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>) {
}: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement> & {
size?: "normal" | "small" | "smaller" | "large" | "larger";
variant?: "normal" | "faded";
truncate?: { lines?: number; width?: number };
}) {
return (
<span
{...props}
className={twMerge("text-base", "twui-span", props.className)}
className={twMerge(
"",
size == "small" && "text-sm",
size == "smaller" && "text-xs",
size == "large" && "text-lg",
size == "larger" && "text-xl",
variant == "faded" && "opacity-50",
truncate ? `` : ``,
"twui-span",
props.className,
)}
>
{props.children}
</span>

View File

@ -0,0 +1,37 @@
import _ from "lodash";
import { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
type Props = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
center?: boolean;
gap?: number | string;
};
/**
* # Flexbox Column
* @className twui-stack
*/
export default function Stack({ gap, ...props }: Props) {
const finalProps = _.omit(props, "center");
return (
<div
{...finalProps}
className={twMerge(
"flex flex-col items-start gap-4",
props.center && "items-center",
gap
? typeof gap == "string"
? `gap-[${gap}]`
: `gap-${gap}`
: "",
"twui-stack",
props.className
)}
>
{props.children}
</div>
);
}

View File

@ -1,21 +1,37 @@
import _ from "lodash";
import { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
type Props = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
center?: boolean;
gap?: number | string;
componentRef?: React.RefObject<HTMLDivElement>;
};
/**
* # Flexbox Column
* @className twui-stack
*/
export default function Stack({
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) {
export default function Stack({ gap, componentRef, ...props }: Props) {
const finalProps = _.omit(props, "center");
return (
<div
{...props}
{...finalProps}
className={twMerge(
"flex flex-col items-start",
"flex flex-col items-start gap-4",
props.center && "items-center",
gap
? typeof gap == "string"
? `gap-[${gap}]`
: `gap-${gap}`
: "",
"twui-stack",
props.className
)}
ref={componentRef}
>
{props.children}
</div>

View File

@ -0,0 +1,106 @@
import React, { ComponentProps, useRef } from "react";
import { twMerge } from "tailwind-merge";
import MarkdownEditorPreviewComponent from "./MarkdownEditorPreviewComponent";
import MarkdownEditorComponent from "./MarkdownEditorComponent";
import MarkdownEditorSelectorButtons from "./MarkdownEditorSelectorButtons";
import Row from "../../layout/Row";
import Stack from "../../layout/Stack";
import AceEditor from "../../editors/AceEditor";
import useStatus from "../../hooks/useStatus";
type Props = {
value?: string;
setValue?: React.Dispatch<React.SetStateAction<string>>;
defaultSideBySide?: boolean;
changeHandler?: (content: string) => void;
editorProps?: ComponentProps<typeof AceEditor>;
maxHeight?: string;
noToggleButtons?: boolean;
};
export default function MarkdownEditor({
value: existingValue,
setValue: setExistingValue,
defaultSideBySide,
changeHandler,
editorProps,
maxHeight: existingMaxHeight,
noToggleButtons,
}: Props) {
const [value, setValue] = React.useState<any>(existingValue || ``);
const [sideBySide, setSideBySide] = React.useState(
defaultSideBySide || false,
);
const [preview, setPreview] = React.useState(false);
const { refresh, setRefresh } = useStatus();
const updatingFromExtValueRef = useRef(false);
const maxHeight = existingMaxHeight || "600px";
React.useEffect(() => {
if (updatingFromExtValueRef.current) return;
setExistingValue?.(value);
changeHandler?.(value);
}, [value]);
React.useEffect(() => {
if (!existingValue) return;
updatingFromExtValueRef.current = true;
setValue(existingValue);
setTimeout(() => {
updatingFromExtValueRef.current = false;
setRefresh((prev) => prev + 1);
}, 500);
}, [existingValue]);
return (
<Stack className="w-full items-stretch">
{!noToggleButtons && (
<MarkdownEditorSelectorButtons
{...{ preview, setPreview, setSideBySide, sideBySide }}
/>
)}
{sideBySide ? (
<Row
className={twMerge(
`w-full grid xl:grid-cols-2 gap-6 max-h-[${maxHeight}]`,
"overflow-auto",
)}
>
<MarkdownEditorComponent
setValue={setValue}
value={value}
maxHeight={maxHeight}
refreshDepArr={[refresh]}
{...editorProps}
/>
<MarkdownEditorPreviewComponent
value={value}
maxHeight={maxHeight}
/>
</Row>
) : (
<Stack
className={twMerge(`w-full max-h-[${maxHeight}] h-full`)}
>
{preview ? (
<MarkdownEditorPreviewComponent
value={value}
maxHeight={maxHeight}
/>
) : (
<MarkdownEditorComponent
setValue={setValue}
value={value}
maxHeight={maxHeight}
refreshDepArr={[refresh]}
{...editorProps}
/>
)}
</Stack>
)}
</Stack>
);
}

View File

@ -0,0 +1,32 @@
import React, { ComponentProps } from "react";
import AceEditor from "../../editors/AceEditor";
type Props = ComponentProps<typeof AceEditor> & {
value: string;
setValue: React.Dispatch<any>;
maxHeight: string;
};
export default function MarkdownEditorComponent({
value,
setValue,
maxHeight,
...props
}: Props) {
return (
<AceEditor
mode="markdown"
content={value}
onChange={(newValue) => {
setValue(newValue);
}}
wrapperProps={{
style: { height: maxHeight },
className: `max-h-[${maxHeight}]`,
}}
placeholder="## Write Some markdown ..."
fontSize="14px"
{...props}
/>
);
}

View File

@ -0,0 +1,77 @@
import React, { ComponentProps, RefObject } from "react";
import { twMerge } from "tailwind-merge";
import { serialize } from "next-mdx-remote/serialize";
import remarkGfm from "remark-gfm";
import rehypePrismPlus from "rehype-prism-plus";
import { MDXRemote, MDXRemoteSerializeResult } from "next-mdx-remote";
import Border from "../../elements/Border";
import { useMDXComponents } from "../mdx-components";
import EmptyContent from "../../elements/EmptyContent";
type Props = {
value: string;
maxHeight: string;
wrapperProps?: ComponentProps<typeof Border>;
};
export default function MarkdownEditorPreviewComponent({
value,
maxHeight,
wrapperProps,
}: Props) {
try {
const [mdxSource, setMdxSource] =
React.useState<
MDXRemoteSerializeResult<
Record<string, unknown>,
Record<string, unknown>
>
>();
const { components } = useMDXComponents();
React.useEffect(() => {
try {
// const { data, content } = matter(value);
const parsedValue = value
.replace(/---([^(---)]+)---/, "")
.trim();
serialize(parsedValue, {
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypePrismPlus],
},
// scope: data,
})
.then((mdxSrc) => {
setMdxSource(mdxSrc);
})
.catch((err) => {});
} catch (error) {}
}, [value]);
return (
<Border
{...wrapperProps}
className={twMerge(
`w-full max-h-[${maxHeight}] h-[${maxHeight}] block px-6 pb-10`,
"overflow-auto",
wrapperProps?.className
)}
>
{mdxSource ? (
<MDXRemote
{...mdxSource}
components={{
...components,
}}
/>
) : null}
</Border>
);
} catch (error) {
return <EmptyContent title={`Markdown Syntax Error.`} />;
}
}

View File

@ -0,0 +1,95 @@
import React from "react";
import Button from "../../layout/Button";
import Border from "../../elements/Border";
type Props = {
preview: boolean;
setPreview: React.Dispatch<React.SetStateAction<boolean>>;
sideBySide: boolean;
setSideBySide: React.Dispatch<React.SetStateAction<boolean>>;
};
export default function MarkdownEditorSelectorButtons({
preview,
setPreview,
setSideBySide,
sideBySide,
}: Props) {
const [ready, setReady] = React.useState(false);
const defSbsName = "TWUIMarkdownEditorDefaultSideBySide";
const defPrevName = "TWUIMarkdownEditorDefaultPreview";
React.useEffect(() => {
if (!ready) return;
if (sideBySide) {
localStorage.setItem(defSbsName, "true");
} else {
localStorage.removeItem(defSbsName);
}
if (preview) {
localStorage.setItem(defPrevName, "true");
} else {
localStorage.removeItem(defPrevName);
}
}, [sideBySide, preview, ready]);
React.useEffect(() => {
if (localStorage.getItem(defPrevName)) {
setPreview(true);
}
if (!localStorage.getItem(defSbsName)) {
setSideBySide(false);
}
setTimeout(() => {
setReady(true);
}, 200);
}, []);
return (
<Border className="p-1.5">
<Button
title="Code"
size="smaller"
color={sideBySide ? "gray" : preview ? "gray" : "primary"}
variant={
sideBySide ? "outlined" : preview ? "outlined" : "normal"
}
onClick={() => {
setSideBySide(false);
setPreview(false);
}}
>
Code
</Button>
<Button
title="Preview"
size="smaller"
color={sideBySide ? "gray" : preview ? "primary" : "gray"}
variant={
sideBySide ? "outlined" : preview ? "normal" : "outlined"
}
onClick={() => {
setSideBySide(false);
setPreview(true);
}}
>
Preview
</Button>
<Button
title="Side By Side"
size="smaller"
color={sideBySide ? "primary" : "gray"}
variant={sideBySide ? "normal" : "outlined"}
onClick={() => {
setSideBySide(true);
}}
>
Side By Side
</Button>
</Border>
);
}

View File

@ -0,0 +1,81 @@
import { MDXComponents } from "mdx/types";
import CodeBlock from "../elements/CodeBlock";
import H1 from "../layout/H1";
import H4 from "../layout/H4";
import React from "react";
import twuiSlugify from "../utils/slugify";
import H2 from "../layout/H2";
import P from "../layout/P";
import H3 from "../layout/H3";
type Params = {
components: MDXComponents;
codeBgColor?: string;
};
export function useMDXComponents(params?: Params) {
const codeBgColor = params?.codeBgColor || "#000000";
return {
components: {
h1: ({ children }) => <H1>{children}</H1>,
h4: ({ children }) => <H4>{children}</H4>,
pre: ({ children, ...props }) => {
if (React.isValidElement(children) && children.props) {
return (
// @ts-ignore
<CodeBlock {...props} backgroundColor={codeBgColor}>
{/* @ts-ignore */}
{children.props.children}
</CodeBlock>
);
}
return (
// @ts-ignore
<CodeBlock {...props} backgroundColor={codeBgColor}>
{children}
</CodeBlock>
);
},
h2: ({ children }) => {
const slug = twuiSlugify(children?.toString());
return (
<a
href={`#${slug}`}
id={slug}
className="twui-docs-header-anchor"
>
<H2 className="pt-4 m-0 mb-4 text-2xl">{children}</H2>
</a>
);
},
h3: ({ children }) => {
const slug = twuiSlugify(children?.toString());
return (
<a
href={`#${slug}`}
id={slug}
className="twui-docs-header-anchor"
>
<H3 className="pt-4 m-0 mb-4 text-xl">{children}</H3>
</a>
);
},
img: (props) => (
// @ts-ignore
<img
{...props}
className="w-full h-auto shadow-lg rounded-default overflow-hidden"
/>
),
p: ({ children }) => (
<P className="mt-2 mb-6 max-w-none py-0">{children}</P>
),
li: ({ children }) => <li className="w-full">{children}</li>,
...params?.components,
} as MDXComponents,
codeBgColor: "rgb(7 12 22)",
};
}

View File

@ -0,0 +1,41 @@
import React from "react";
import type { MDXComponents } from "mdx/types";
import H1 from "../../layout/H1";
import H2 from "../../layout/H2";
import H3 from "../../layout/H3";
import H4 from "../../layout/H4";
import CodeBlock from "../../elements/CodeBlock";
import { twMerge } from "tailwind-merge";
type Params = {
components: MDXComponents;
codeBgColor?: string;
};
export default function useMDXComponents({
components,
codeBgColor,
}: Params): MDXComponents {
return {
h1: ({ children }) => <H1>{children}</H1>,
h4: ({ children }) => <H4>{children}</H4>,
pre: ({ children, ...props }) => {
if (React.isValidElement(children) && children.props) {
return (
// @ts-ignore
<CodeBlock {...props} backgroundColor={codeBgColor}>
{/* @ts-ignore */}
{children.props.children}
</CodeBlock>
);
}
return (
// @ts-ignore
<CodeBlock {...props} backgroundColor={codeBgColor}>
{children}
</CodeBlock>
);
},
...components,
};
}

31
components/package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "tailwind-ui",
"type": "module",
"dependencies": {
"@xterm/xterm": "latest",
"lodash": "latest",
"lucide-react": "latest",
"react-code-blocks": "latest",
"react-responsive-modal": "latest",
"tailwind-merge": "latest",
"typescript": "latest",
"mdx/types": "latest",
"gray-matter": "latest",
"next-mdx-remote": "latest",
"remark-gfm": "latest",
"rehype-prism-plus": "latest",
"html-to-react": "^1.7.0"
},
"devDependencies": {
"@types/ace": "latest",
"@types/bun": "latest",
"@types/lodash": "latest",
"@types/node": "latest",
"@types/react": "latest",
"@types/react-dom": "latest",
"postcss": "latest",
"tailwindcss": "^4",
"@types/mdx": "latest",
"@next/mdx": "latest"
}
}

Some files were not shown because too many files have changed in this diff Show More