This commit is contained in:
Benjamin Toby
2025-07-20 10:35:54 +01:00
parent dd1d05251d
commit a0a0ab8ee4
99 changed files with 5678 additions and 909 deletions
+2 -2
View File
@@ -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"
+151 -51
View File
@@ -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;
}
+18 -25
View File
@@ -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>
);
}
+5 -6
View File
@@ -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
+73 -11
View File
@@ -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>
);
}
+46 -12
View File
@@ -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>
+88
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;
}
+33
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>
);
}
+97
View File
@@ -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>
);
}
+157
View File
@@ -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>
);
}
+2 -2
View File
@@ -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>
);
}
+161 -54
View File
@@ -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);
}
+126
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>
);
}
+5 -1
View File
@@ -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
)}
+9
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 />;
}
+15 -8
View File
@@ -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"
+76
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 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>
);
}
+44 -17
View File
@@ -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>
);
}
+29 -13
View File
@@ -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>
);
+38 -49
View File
@@ -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);
}
+16 -12
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>
);
}