Updates
This commit is contained in:
@@ -17,8 +17,8 @@ export default function Border({ spacing, ...props }: TWUI_BORDER_PROPS) {
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"relative flex items-center gap-2 border border-solid rounded",
|
||||
"border-slate-300 dark:border-white/10",
|
||||
"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"
|
||||
|
||||
@@ -1,60 +1,63 @@
|
||||
import React from "react";
|
||||
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-current-breadcrumb-link`
|
||||
* @className `twui-breadcrumb-link`
|
||||
* @className `twui-current-breadcrumb-wrapper`
|
||||
* @className `twui-breadcrumbs-divider`
|
||||
*/
|
||||
export default function Breadcrumbs({ excludeRegexMatch }: Props) {
|
||||
const [links, setLinks] = React.useState<LinkObject[] | null>(null);
|
||||
const [current, setCurrent] = React.useState(false);
|
||||
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 pathLinks = pathname.split("/");
|
||||
|
||||
let validPathLinks = [];
|
||||
|
||||
validPathLinks.push({
|
||||
title: "Home",
|
||||
path: pathname.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;
|
||||
})(),
|
||||
});
|
||||
let validPathLinks = twuiBreadcrumbsGenerateLinksFromUrl({
|
||||
url: pathname,
|
||||
excludeRegexMatch,
|
||||
skipHome,
|
||||
});
|
||||
|
||||
setLinks(validPathLinks);
|
||||
@@ -69,13 +72,48 @@ export default function Breadcrumbs({ excludeRegexMatch }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
<nav
|
||||
className={twMerge(
|
||||
"overflow-x-auto max-w-[70vw]",
|
||||
"overflow-x-auto",
|
||||
"twui-current-breadcrumb-wrapper"
|
||||
)}
|
||||
aria-label="Breadcrumb"
|
||||
>
|
||||
<Row className="gap-4 flex-nowrap whitespace-nowrap overflow-x-auto w-full">
|
||||
<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",
|
||||
backButtonProps?.className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
window.history.back();
|
||||
backButtonProps?.onClick?.(e);
|
||||
}}
|
||||
title="Breadcrumbs Back Button"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</Button>
|
||||
{divider || (
|
||||
<Divider
|
||||
vertical
|
||||
className={twMerge(
|
||||
"twui-breadcrumbs-divider",
|
||||
dividerProps?.className
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
{links.map((linkObject, index, array) => {
|
||||
const isTarget = array.length - 1 == index;
|
||||
|
||||
@@ -84,13 +122,21 @@ export default function Breadcrumbs({ excludeRegexMatch }: Props) {
|
||||
<Link
|
||||
key={index}
|
||||
href={linkObject.path}
|
||||
{...linkProps}
|
||||
{...(isTarget ? currentLinkProps : {})}
|
||||
className={twMerge(
|
||||
"text-slate-400 dark:text-slate-500 pointer-events-none text-xs",
|
||||
"text-primary-text/50 dark:text-primary-dark-text/50 text-xs",
|
||||
"max-w-[200px] text-ellipsis overflow-hidden",
|
||||
isTarget ? "current" : "",
|
||||
"twui-current-breadcrumb-link"
|
||||
"twui-breadcrumb-link",
|
||||
linkProps?.className,
|
||||
isTarget && currentLinkProps?.className
|
||||
)}
|
||||
title={
|
||||
currentLinkProps?.title || linkObject.title
|
||||
}
|
||||
>
|
||||
{linkObject.title}
|
||||
{currentTitle || linkObject.title}
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
@@ -98,30 +144,84 @@ export default function Breadcrumbs({ excludeRegexMatch }: Props) {
|
||||
<React.Fragment key={index}>
|
||||
<Link
|
||||
href={linkObject.path}
|
||||
{...linkProps}
|
||||
{...(isTarget ? currentLinkProps : {})}
|
||||
className={twMerge(
|
||||
"text-xs",
|
||||
isTarget ? "current" : "",
|
||||
"twui-current-breadcrumb-link"
|
||||
"twui-breadcrumb-link",
|
||||
linkProps?.className,
|
||||
isTarget && currentLinkProps?.className
|
||||
)}
|
||||
>
|
||||
{linkObject.title}
|
||||
{currentLinkProps?.title ||
|
||||
linkObject.title}
|
||||
</Link>
|
||||
<Divider vertical />
|
||||
{divider || (
|
||||
<Divider
|
||||
vertical
|
||||
{...dividerProps}
|
||||
className={twMerge(
|
||||
"twui-breadcrumbs-divider",
|
||||
dividerProps?.className
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</Row>
|
||||
</div>
|
||||
</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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import React, {
|
||||
ComponentProps,
|
||||
DetailedHTMLProps,
|
||||
HTMLAttributes,
|
||||
} from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Link from "../layout/Link";
|
||||
|
||||
type Props = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
@@ -7,11 +12,10 @@ type Props = DetailedHTMLProps<
|
||||
> & {
|
||||
variant?: "normal";
|
||||
href?: string;
|
||||
linkProps?: DetailedHTMLProps<
|
||||
React.AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||
HTMLAnchorElement
|
||||
>;
|
||||
linkProps?: ComponentProps<typeof Link>;
|
||||
noHover?: boolean;
|
||||
elRef?: React.RefObject<HTMLDivElement>;
|
||||
linkRef?: React.RefObject<HTMLAnchorElement>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -27,20 +31,18 @@ export default function Card({
|
||||
variant,
|
||||
linkProps,
|
||||
noHover,
|
||||
elRef,
|
||||
linkRef,
|
||||
...props
|
||||
}: Props) {
|
||||
const component = (
|
||||
<div
|
||||
ref={elRef}
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-row items-center p-4 rounded bg-white dark:bg-white/10",
|
||||
"flex flex-row items-center p-4 rounded-default bg-white dark:bg-white/10",
|
||||
"border border-slate-200 dark:border-white/10 border-solid",
|
||||
noHover
|
||||
? ""
|
||||
: href
|
||||
? "hover:bg-slate-100 dark:hover:bg-white/30 hover:border-slate-400 dark:hover:border-white/20"
|
||||
: "",
|
||||
"twui-card",
|
||||
noHover ? "" : "twui-card",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
@@ -50,28 +52,19 @@ export default function Card({
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a
|
||||
<Link
|
||||
ref={linkRef}
|
||||
href={href}
|
||||
{...linkProps}
|
||||
onClick={(e) => {
|
||||
const targetEl = e.target as HTMLElement;
|
||||
if (targetEl.closest(".nested-link")) {
|
||||
e.preventDefault();
|
||||
} else if (e.ctrlKey) {
|
||||
window.open(href, "_blank");
|
||||
} else {
|
||||
window.location.href = href;
|
||||
}
|
||||
linkProps?.onClick?.(e);
|
||||
}}
|
||||
className={twMerge(
|
||||
"cursor-pointer",
|
||||
"twui-card",
|
||||
"twui-card-link",
|
||||
linkProps?.className
|
||||
)}
|
||||
>
|
||||
{component}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export default function CodeBlock({
|
||||
language,
|
||||
...props
|
||||
}: Props) {
|
||||
const codeRef = React.useRef<HTMLDivElement>();
|
||||
const codeRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
@@ -52,9 +52,9 @@ export default function CodeBlock({
|
||||
<div
|
||||
{...wrapperProps}
|
||||
className={twMerge(
|
||||
"outline outline-[1px] outline-slate-200 dark:outline-white/10",
|
||||
"outline-[1px] outline-slate-200 dark:outline-white/10",
|
||||
`rounded w-full transition-all items-start`,
|
||||
"relative",
|
||||
"relative max-w-[80vw] sm:max-w-[85vw] xl:max-w-[880px]",
|
||||
"twui-code-block-wrapper",
|
||||
wrapperProps?.className
|
||||
)}
|
||||
@@ -62,7 +62,6 @@ export default function CodeBlock({
|
||||
boxShadow: copied
|
||||
? "0 0 10px 10px rgba(18, 139, 99, 0.2)"
|
||||
: undefined,
|
||||
maxWidth: "calc(100vw - 80px)",
|
||||
backgroundColor: finalBackgroundColor,
|
||||
...props.style,
|
||||
}}
|
||||
@@ -99,7 +98,7 @@ export default function CodeBlock({
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
beforeIcon={<Copy size={17} color="white" />}
|
||||
className="!p-1 !bg-transparent"
|
||||
className="!p-1 !bg-transparent opacity-50"
|
||||
onClick={() => {
|
||||
const content =
|
||||
codeRef.current?.textContent;
|
||||
@@ -136,7 +135,7 @@ export default function CodeBlock({
|
||||
<pre
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"!my-0",
|
||||
"!my-0 whitespace-pre-wrap",
|
||||
language ? `language-${language}` : "",
|
||||
"twui-code-block-pre",
|
||||
props.className
|
||||
|
||||
@@ -1,26 +1,71 @@
|
||||
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({
|
||||
active,
|
||||
setActive,
|
||||
toggleProps,
|
||||
active: initialActive,
|
||||
setActive: externalSetActive,
|
||||
iconWrapperProps,
|
||||
defaultScheme,
|
||||
...props
|
||||
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||
toggleProps?: TWUI_TOGGLE_PROPS;
|
||||
active: boolean;
|
||||
setActive: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}) {
|
||||
}: 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]);
|
||||
|
||||
@@ -33,7 +78,24 @@ export default function ColorSchemeSelector({
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,11 @@ import { twMerge } from "tailwind-merge";
|
||||
|
||||
export const TWUIDropdownContentPositions = [
|
||||
"left",
|
||||
"bottom-left",
|
||||
"top-left",
|
||||
"right",
|
||||
"bottom-right",
|
||||
"top-right",
|
||||
"center",
|
||||
] as const;
|
||||
|
||||
@@ -23,15 +27,17 @@ export type TWUI_DROPDOWN_PROPS = PropsWithChildren &
|
||||
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;
|
||||
};
|
||||
|
||||
let timeout: any;
|
||||
|
||||
/**
|
||||
* # Toggle Component
|
||||
* @className_wrapper twui-dropdown-wrapper
|
||||
@@ -45,16 +51,24 @@ export default function Dropdown({
|
||||
targetWrapperProps,
|
||||
hoverOpen,
|
||||
above,
|
||||
debounce = 500,
|
||||
debounce = 200,
|
||||
openDebounce = 200,
|
||||
target,
|
||||
position = "center",
|
||||
topOffset,
|
||||
externalSetOpen,
|
||||
keepOpen,
|
||||
disableClickActions,
|
||||
externalOpen,
|
||||
...props
|
||||
}: TWUI_DROPDOWN_PROPS) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
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;
|
||||
@@ -71,12 +85,17 @@ export default function Dropdown({
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (keepOpen) return;
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
setOpen(externalOpen);
|
||||
}, [externalOpen]);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
@@ -88,11 +107,18 @@ export default function Dropdown({
|
||||
onMouseEnter={() => {
|
||||
if (!hoverOpen) return;
|
||||
window.clearTimeout(timeout);
|
||||
externalSetOpen?.(true);
|
||||
setOpen(true);
|
||||
window.clearTimeout(openTimeout);
|
||||
|
||||
openTimeout = setTimeout(() => {
|
||||
externalSetOpen?.(true);
|
||||
setOpen(true);
|
||||
}, openDebounce);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
onMouseLeave={(e) => {
|
||||
if (!hoverOpen) return;
|
||||
|
||||
window.clearTimeout(openTimeout);
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
externalSetOpen?.(false);
|
||||
setOpen(false);
|
||||
@@ -107,6 +133,7 @@ export default function Dropdown({
|
||||
onClick={(e) => {
|
||||
const targetEl = e.target as HTMLElement | null;
|
||||
if (targetEl?.closest?.(".cancel-link")) return;
|
||||
if (disableClickActions) return;
|
||||
externalSetOpen?.(!open);
|
||||
setOpen(!open);
|
||||
}}
|
||||
@@ -122,12 +149,18 @@ export default function Dropdown({
|
||||
<div
|
||||
{...contentWrapperProps}
|
||||
className={twMerge(
|
||||
"absolute z-10",
|
||||
"absolute z-10 mt-1",
|
||||
position == "left"
|
||||
? "left-0"
|
||||
? "left-[100%] top-[50%] -translate-y-[50%]"
|
||||
: position == "right"
|
||||
? "right-0"
|
||||
: "",
|
||||
? "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%]"
|
||||
: "top-[100%]",
|
||||
above ? "-translate-y-[120%]" : "",
|
||||
open ? "flex" : "hidden",
|
||||
"twui-dropdown-content",
|
||||
@@ -142,9 +175,10 @@ export default function Dropdown({
|
||||
window.clearTimeout(timeout);
|
||||
}}
|
||||
style={{
|
||||
top: `calc(100% + ${topOffset || 0}px)`,
|
||||
// top: `calc(100% + ${topOffset || 0}px)`,
|
||||
...contentWrapperProps?.style,
|
||||
}}
|
||||
ref={dropdownContentRef}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
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 === "/") {
|
||||
} 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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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="bottom-right"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
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;
|
||||
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",
|
||||
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.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
|
||||
)}
|
||||
strict={link.strict}
|
||||
onClick={(e) => {
|
||||
link.onClick?.(e);
|
||||
link.linkProps?.onClick?.(e);
|
||||
}}
|
||||
>
|
||||
<Row>
|
||||
{!link.iconPosition ||
|
||||
link.iconPosition == "before"
|
||||
? link.icon
|
||||
: null}
|
||||
{link.title}
|
||||
{link.iconPosition == "after"
|
||||
? link.icon
|
||||
: null}
|
||||
</Row>
|
||||
</Link>
|
||||
{finalDivider}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,8 +35,8 @@ export default function Loading({ size, svgClassName, ...props }: Props) {
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={twMerge(
|
||||
"text-gray-200 animate-spin dark:text-gray-600 fill-blue-600",
|
||||
"twui-loading",
|
||||
"text-gray animate-spin dark:text-gray-dark fill-primary",
|
||||
"dark:fill-white twui-loading",
|
||||
sizeClassName,
|
||||
svgClassName
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ComponentProps, DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Center from "../layout/Center";
|
||||
import Loading from "./Loading";
|
||||
|
||||
type Props = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
loadingProps?: ComponentProps<typeof Loading>;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Loading Overlay Component
|
||||
* @className_wrapper twui-loading-overlay
|
||||
*/
|
||||
export default function LoadingOverlay({ loadingProps, ...props }: Props) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"absolute top-0 left-0 w-full h-full z-[500]",
|
||||
"bg-background-light/90 dark:bg-background-dark/90",
|
||||
props.className,
|
||||
"twui-loading-overlay"
|
||||
)}
|
||||
>
|
||||
<Center>
|
||||
<Loading {...loadingProps} />
|
||||
</Center>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +1,181 @@
|
||||
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import ModalComponent from "../(partials)/ModalComponent";
|
||||
import PopoverComponent from "../(partials)/PopoverComponent";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import Paper from "./Paper";
|
||||
|
||||
type Props = DetailedHTMLProps<
|
||||
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.MutableRefObject<HTMLDivElement | undefined>;
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Modal Component
|
||||
* @className_wrapper twui-modal-root
|
||||
* @className_wrapper twui-modal
|
||||
* @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({ target, targetRef, ...props }: Props) {
|
||||
const [wrapper, setWrapper] = React.useState<HTMLDivElement | null>(null);
|
||||
export default function Modal(props: TWUI_MODAL_PROPS) {
|
||||
const {
|
||||
target,
|
||||
targetRef,
|
||||
targetWrapperProps,
|
||||
open: existingOpen,
|
||||
setOpen: existingSetOpen,
|
||||
isPopover,
|
||||
popoverReferenceRef,
|
||||
trigger = "hover",
|
||||
debounce = 500,
|
||||
onClose,
|
||||
} = props;
|
||||
|
||||
const [ready, setReady] = React.useState(false);
|
||||
const [open, setOpen] = React.useState(existingOpen || false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const wrapperEl = document.createElement("div");
|
||||
const IDName = isPopover ? "twui-popover-root" : "twui-modal-root";
|
||||
const modalRoot = document.getElementById(IDName);
|
||||
|
||||
wrapperEl.className = twMerge(
|
||||
"fixed z-[200000] top-0 left-0 w-screen h-screen",
|
||||
"flex flex-col items-center justify-center",
|
||||
"twui-modal-root"
|
||||
);
|
||||
|
||||
setWrapper(wrapperEl);
|
||||
if (modalRoot) {
|
||||
setReady(true);
|
||||
} else {
|
||||
const newModalRootEl = document.createElement("div");
|
||||
newModalRootEl.id = IDName;
|
||||
document.body.appendChild(newModalRootEl);
|
||||
setReady(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const modalEl = (
|
||||
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>
|
||||
<div
|
||||
className={twMerge(
|
||||
"absolute top-0 left-0 bg-slate-900/80 z-0",
|
||||
"w-screen h-screen"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
closeModal({ wrapperEl: wrapper });
|
||||
}}
|
||||
></div>
|
||||
<Paper
|
||||
{...props}
|
||||
className={twMerge("z-10 max-w-[500px]", props.className)}
|
||||
>
|
||||
{props.children}
|
||||
</Paper>
|
||||
{target ? (
|
||||
<div
|
||||
{...targetWrapperProps}
|
||||
onClick={(e) => setOpen(!open)}
|
||||
ref={finalTargetRef}
|
||||
onMouseEnter={
|
||||
isPopover && trigger === "hover"
|
||||
? popoverEnterFn
|
||||
: targetWrapperProps?.onMouseEnter
|
||||
}
|
||||
onMouseLeave={
|
||||
isPopover && trigger === "hover"
|
||||
? 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>
|
||||
);
|
||||
|
||||
const targetEl = (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
if (!wrapper) return;
|
||||
document.body.appendChild(wrapper);
|
||||
const root = createRoot(wrapper);
|
||||
root.render(modalEl);
|
||||
}}
|
||||
ref={targetRef as any}
|
||||
>
|
||||
{target}
|
||||
</div>
|
||||
);
|
||||
|
||||
return targetEl;
|
||||
}
|
||||
|
||||
function closeModal({ wrapperEl }: { wrapperEl: HTMLDivElement | null }) {
|
||||
if (!wrapperEl) return;
|
||||
wrapperEl.parentElement?.removeChild(wrapperEl);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import React, { DetailedHTMLProps, HTMLAttributes, RefObject } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/**
|
||||
@@ -8,6 +8,7 @@ import { twMerge } from "tailwind-merge";
|
||||
export default function Paper({
|
||||
variant,
|
||||
linkProps,
|
||||
componentRef,
|
||||
...props
|
||||
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||
variant?: "normal";
|
||||
@@ -15,13 +16,16 @@ export default function Paper({
|
||||
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-white dark:bg-white/10 gap-4",
|
||||
"border border-slate-200 dark:border-white/10 border-solid w-full",
|
||||
"relative",
|
||||
"twui-paper",
|
||||
props.className
|
||||
)}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -3,11 +3,7 @@ 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,
|
||||
InputHTMLAttributes,
|
||||
TextareaHTMLAttributes,
|
||||
} from "react";
|
||||
import React, { DetailedHTMLProps } from "react";
|
||||
|
||||
let timeout: any;
|
||||
|
||||
@@ -16,12 +12,15 @@ export type SearchProps<KeyType extends string> = DetailedHTMLProps<
|
||||
HTMLDivElement
|
||||
> & {
|
||||
dispatch?: (value?: string) => void;
|
||||
changeHandler?: (value?: string) => void;
|
||||
delay?: number;
|
||||
inputProps?: InputProps<KeyType>;
|
||||
buttonProps?: DetailedHTMLProps<
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
HTMLButtonElement
|
||||
>;
|
||||
loading?: boolean;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -32,9 +31,12 @@ export type SearchProps<KeyType extends string> = DetailedHTMLProps<
|
||||
*/
|
||||
export default function Search<KeyType extends string>({
|
||||
dispatch,
|
||||
changeHandler,
|
||||
delay = 500,
|
||||
inputProps,
|
||||
buttonProps,
|
||||
loading,
|
||||
placeholder,
|
||||
...props
|
||||
}: SearchProps<KeyType>) {
|
||||
const [input, setInput] = React.useState("");
|
||||
@@ -44,10 +46,11 @@ export default function Search<KeyType extends string>({
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
dispatch?.(input);
|
||||
changeHandler?.(input);
|
||||
}, delay);
|
||||
}, [input]);
|
||||
|
||||
const inputRef = React.useRef<HTMLInputElement>();
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (props.autoFocus) {
|
||||
@@ -66,7 +69,7 @@ export default function Search<KeyType extends string>({
|
||||
>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search"
|
||||
placeholder={placeholder || "Search"}
|
||||
{...inputProps}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
@@ -81,17 +84,21 @@ export default function Search<KeyType extends string>({
|
||||
componentRef={inputRef}
|
||||
/>
|
||||
<Button
|
||||
loadingProps={{ size: "small" }}
|
||||
{...buttonProps}
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
className={twMerge(
|
||||
"rounded-l-none my-[1px]",
|
||||
"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"
|
||||
|
||||
@@ -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 text-sm 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-sm text-foreground-light",
|
||||
"dark:text-foreground-dark max-w-[200px] overflow-hidden",
|
||||
"overflow-ellipsis"
|
||||
)}
|
||||
title={row[header]}
|
||||
>
|
||||
{row[header]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
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 Span from "../layout/Span";
|
||||
import twuiSlugify from "../utils/slugify";
|
||||
|
||||
export type TWUITabsObject = {
|
||||
title: string;
|
||||
value: string;
|
||||
value?: string;
|
||||
content: React.ReactNode;
|
||||
defaultActive?: boolean;
|
||||
};
|
||||
|
||||
export type TWUI_TOGGLE_PROPS = React.ComponentProps<typeof Stack> & {
|
||||
tabsContentArray: TWUITabsObject[];
|
||||
tabsContentArray: (TWUITabsObject | TWUITabsObject[] | undefined | null)[];
|
||||
tabsBorderProps?: React.ComponentProps<typeof Border>;
|
||||
tabsButtonsWrapperProps?: React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
@@ -21,6 +21,11 @@ export type TWUI_TOGGLE_PROPS = React.ComponentProps<typeof Stack> & {
|
||||
>;
|
||||
centered?: boolean;
|
||||
debounce?: number;
|
||||
/**
|
||||
* React Component to display when switching
|
||||
*/
|
||||
switchComponent?: ReactNode;
|
||||
setActiveValue?: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -36,20 +41,37 @@ export default function Tabs({
|
||||
tabsButtonsWrapperProps,
|
||||
centered,
|
||||
debounce = 100,
|
||||
switchComponent,
|
||||
setActiveValue: existingSetActiveValue,
|
||||
...props
|
||||
}: TWUI_TOGGLE_PROPS) {
|
||||
const values = tabsContentArray.map((obj) => obj.value);
|
||||
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(
|
||||
tabsContentArray.find((ctn) => ctn.defaultActive)?.value ||
|
||||
values[0] ||
|
||||
undefined
|
||||
defaultActiveObj
|
||||
? defaultActiveObj?.value || twuiSlugify(defaultActiveObj.title)
|
||||
: values[0] || undefined
|
||||
);
|
||||
|
||||
const targetContent = tabsContentArray.find(
|
||||
(ctn) => ctn.value == activeValue
|
||||
const targetContent = finalTabsContentArray.find(
|
||||
(ctn) =>
|
||||
ctn.value == activeValue || twuiSlugify(ctn.title) == activeValue
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
existingSetActiveValue?.(activeValue);
|
||||
}, [activeValue]);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
{...props}
|
||||
@@ -63,16 +85,21 @@ export default function Tabs({
|
||||
tabsButtonsWrapperProps?.className
|
||||
)}
|
||||
>
|
||||
<Border className="p-0 w-full" {...tabsBorderProps}>
|
||||
<Border
|
||||
className="p-0 w-full overflow-hidden"
|
||||
{...tabsBorderProps}
|
||||
>
|
||||
<Row
|
||||
className={twMerge(
|
||||
"gap-0 items-stretch w-full",
|
||||
"gap-0 items-stretch w-full flex-nowrap overflow-x-auto",
|
||||
centered && "justify-center"
|
||||
)}
|
||||
>
|
||||
{values.map((value, index) => {
|
||||
const targetObject = tabsContentArray.find(
|
||||
(ctn) => ctn.value == value
|
||||
const targetObject = finalTabsContentArray.find(
|
||||
(ctn) =>
|
||||
ctn.value == value ||
|
||||
twuiSlugify(ctn.title) == value
|
||||
);
|
||||
|
||||
const isActive = value == activeValue;
|
||||
@@ -80,9 +107,9 @@ export default function Tabs({
|
||||
return (
|
||||
<span
|
||||
className={twMerge(
|
||||
"px-6 py-2 rounded -ml-[1px]",
|
||||
"px-6 py-2 rounded-default -ml-[1px] whitespace-nowrap",
|
||||
isActive
|
||||
? "bg-blue-500 text-white outline-none twui-tab-button-active"
|
||||
? "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"
|
||||
@@ -102,7 +129,7 @@ export default function Tabs({
|
||||
</Row>
|
||||
</Border>
|
||||
</div>
|
||||
{targetContent?.content}
|
||||
{activeValue ? targetContent?.content : switchComponent || null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,57 +16,68 @@ export type TWUI_TOGGLE_PROPS = PropsWithChildren &
|
||||
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 outline-0",
|
||||
"text-xs px-2 py-0.5 rounded-full outline-0",
|
||||
"text-center flex items-center justify-center",
|
||||
color == "secondary"
|
||||
? "bg-violet-600 outline-violet-600"
|
||||
? "bg-secondary text-white outline-secbg-secondary"
|
||||
: color == "success"
|
||||
? "bg-emerald-700 outline-emerald-700"
|
||||
? "bg-success outline-success text-white"
|
||||
: color == "error"
|
||||
? "bg-orange-700 outline-orange-700"
|
||||
: color == "gray"
|
||||
? "bg-slate-100 outline-slate-200 dark:bg-white/10 dark:outline-white/20 text-slate-500 dark:text-white"
|
||||
: "bg-blue-600 outline-blue-600",
|
||||
? twMerge(
|
||||
"bg-slate-100 outline-slate-200 dark:bg-white/10 dark:outline-white/20",
|
||||
"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-violet-600"
|
||||
? "text-secondary"
|
||||
: color == "success"
|
||||
? "text-emerald-700 dark:text-emerald-400"
|
||||
? "text-success dark:text-success-dark"
|
||||
: color == "error"
|
||||
? "text-orange-700"
|
||||
: color == "gray"
|
||||
? "text-slate-700 dark:text-white/80"
|
||||
: "text-blue-600")
|
||||
: "text-primary dark:text-primary-dark twui-tag-primary-outlined")
|
||||
: variant == "ghost"
|
||||
? "!bg-transparent outline-none border-none " +
|
||||
(color == "secondary"
|
||||
? "text-violet-600"
|
||||
? "text-secondary"
|
||||
: color == "success"
|
||||
? "text-emerald-700 dark:text-emerald-400"
|
||||
? "text-success dark:text-success-dark"
|
||||
: color == "error"
|
||||
? "text-orange-700"
|
||||
: color == "gray"
|
||||
? "text-slate-700 dark:text-white/80"
|
||||
: "text-blue-600")
|
||||
: "text-white",
|
||||
: "text-primary dark:text-primary-dark")
|
||||
: "",
|
||||
|
||||
"twui-tag",
|
||||
props.className
|
||||
@@ -78,7 +89,12 @@ export default function Tag({
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a href={href} className={twMerge("hover:opacity-80")}>
|
||||
<a
|
||||
href={href}
|
||||
target={newTab ? "_blank" : undefined}
|
||||
{...linkProps}
|
||||
className={twMerge("hover:opacity-80", linkProps?.className)}
|
||||
>
|
||||
{mainComponent}
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { createRoot } from "react-dom/client";
|
||||
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;
|
||||
@@ -35,17 +35,47 @@ export default function Toast({
|
||||
color,
|
||||
...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);
|
||||
}, closeDelay);
|
||||
|
||||
return function () {
|
||||
setOpen?.(false);
|
||||
};
|
||||
}, [ready, open]);
|
||||
|
||||
if (!ready) return null;
|
||||
if (!open) return null;
|
||||
|
||||
const toastEl = (
|
||||
return ReactDOM.createPortal(
|
||||
<Card
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"pl-6 pr-8 py-4 bg-blue-700 dark:bg-blue-800",
|
||||
"absolute bottom-4 right-4 z-[250] border-none",
|
||||
"pl-6 pr-8 py-4 bg-primary dark:bg-primary-dark",
|
||||
color == "success"
|
||||
? "bg-emerald-600 dark:bg-emerald-700 twui-toast-success"
|
||||
? "bg-success dark:bg-success-dark twui-toast-success"
|
||||
: color == "error"
|
||||
? "bg-orange-600 dark:bg-orange-700 twui-toast-error"
|
||||
? "bg-error dark:bg-error-dark twui-toast-error"
|
||||
: "",
|
||||
props.className,
|
||||
"twui-toast"
|
||||
@@ -54,13 +84,7 @@ export default function Toast({
|
||||
window.clearTimeout(timeout);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
const targetEl = e.target as HTMLElement;
|
||||
const rootWrapperEl = targetEl.closest(
|
||||
".twui-toast-root"
|
||||
) as HTMLDivElement | null;
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
closeToast({ wrapperEl: rootWrapperEl });
|
||||
setOpen?.(false);
|
||||
}, closeDelay);
|
||||
}}
|
||||
@@ -71,48 +95,13 @@ export default function Toast({
|
||||
"text-white"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
const targetEl = e.target as HTMLElement;
|
||||
const rootWrapperEl = targetEl.closest(".twui-toast-root");
|
||||
|
||||
if (rootWrapperEl) {
|
||||
rootWrapperEl.parentElement?.removeChild(rootWrapperEl);
|
||||
setOpen?.(false);
|
||||
}
|
||||
setOpen?.(false);
|
||||
}}
|
||||
>
|
||||
<X size={15} />
|
||||
</Span>
|
||||
<Span className={twMerge("text-white")}>{props.children}</Span>
|
||||
</Card>
|
||||
</Card>,
|
||||
document.getElementById(IDName) as HTMLElement
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const wrapperEl = document.createElement("div");
|
||||
|
||||
wrapperEl.className = twMerge(
|
||||
"fixed z-[200000] bottom-10 right-10",
|
||||
"flex flex-col items-center justify-center",
|
||||
"twui-toast-root"
|
||||
);
|
||||
|
||||
document.body.appendChild(wrapperEl);
|
||||
const root = createRoot(wrapperEl);
|
||||
root.render(toastEl);
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
closeToast({ wrapperEl });
|
||||
setOpen?.(false);
|
||||
}, closeDelay);
|
||||
|
||||
return function () {
|
||||
closeToast({ wrapperEl });
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function closeToast({ wrapperEl }: { wrapperEl: HTMLDivElement | null }) {
|
||||
if (!wrapperEl) return;
|
||||
wrapperEl.parentElement?.removeChild(wrapperEl);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user