This commit is contained in:
Benjamin Toby
2025-01-05 07:25:38 +01:00
parent 998158369a
commit 5587024789
30 changed files with 4756 additions and 75 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ export default function Border({ spacing, ...props }: TWUI_BORDER_PROPS) {
{...props}
className={twMerge(
"relative flex items-center gap-2 border rounded",
"border-slate-300 dark:border-white/20",
"border-slate-300 dark:border-white/10",
spacing
? spacing == "normal"
? "px-3 py-2"
+2 -2
View File
@@ -23,7 +23,7 @@ export default function Breadcrumbs() {
});
pathLinks.forEach((linkText, index, array) => {
if (!linkText?.match(/./) || index == 1) {
if (!linkText?.match(/./)) {
return;
}
@@ -56,7 +56,7 @@ export default function Breadcrumbs() {
}
return (
<Row className="gap-4">
<Row className="gap-4 flex-nowrap whitespace-nowrap overflow-x-auto w-full">
{links.map((linkObject, index, array) => {
if (index === links.length - 1) {
return (
+28 -3
View File
@@ -3,7 +3,11 @@ import { twMerge } from "tailwind-merge";
/**
* # General Card
* @className_wrapper twui-card
* @className twui-card
* @className twui-card-link
*
* @info use the classname `nested-link` to prevent the card from being clickable when
* a link (or the target element with this calss) inside the card is clicked.
*/
export default function Card({
href,
@@ -24,7 +28,9 @@ export default function Card({
className={twMerge(
"flex flex-row items-center p-4 rounded bg-white dark:bg-white/10",
"border border-slate-200 dark:border-white/10 border-solid",
href ? "hover:bg-slate-100 dark:hover:bg-white/30" : "",
href
? "hover:bg-slate-100 dark:hover:bg-white/30 hover:border-slate-400 dark:hover:border-white/20"
: "",
"twui-card",
props.className
)}
@@ -35,7 +41,26 @@ export default function Card({
if (href) {
return (
<a href={href} {...linkProps}>
<a
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-link",
linkProps?.className
)}
>
{component}
</a>
);
+146
View File
@@ -0,0 +1,146 @@
import React, {
DetailedHTMLProps,
HTMLAttributes,
PropsWithChildren,
} from "react";
import { twMerge } from "tailwind-merge";
export const TWUIDropdownContentPositions = [
"left",
"right",
"center",
] as const;
export type TWUI_DROPDOWN_PROPS = PropsWithChildren &
DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
target: React.ReactNode;
contentWrapperProps?: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
targetWrapperProps?: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
debounce?: number;
hoverOpen?: boolean;
position?: (typeof TWUIDropdownContentPositions)[number];
topOffset?: number;
externalSetOpen?: React.Dispatch<React.SetStateAction<boolean>>;
};
let timeout: any;
/**
* # Toggle Component
* @className_wrapper twui-dropdown-wrapper
* @className_wrapper twui-dropdown-target
* @className_wrapper twui-dropdown-content
*/
export default function Dropdown({
contentWrapperProps,
targetWrapperProps,
hoverOpen,
debounce = 500,
target,
position = "center",
topOffset,
externalSetOpen,
...props
}: TWUI_DROPDOWN_PROPS) {
const [open, setOpen] = React.useState(false);
const dropdownRef = React.useRef<HTMLDivElement>(null);
const handleClickOutside = React.useCallback((e: MouseEvent) => {
const targetEl = e.target as HTMLElement;
const closestWrapper = targetEl.closest(".twui-dropdown-wrapper");
if (!closestWrapper) {
externalSetOpen?.(false);
return setOpen(false);
}
if (closestWrapper && closestWrapper !== dropdownRef.current) {
externalSetOpen?.(false);
return setOpen(false);
}
}, []);
React.useEffect(() => {
document.addEventListener("click", handleClickOutside);
return () => {
document.removeEventListener("click", handleClickOutside);
};
}, []);
return (
<div
{...props}
className={twMerge(
"flex flex-col items-center relative",
"twui-dropdown-wrapper",
props.className
)}
onMouseEnter={() => {
if (!hoverOpen) return;
window.clearTimeout(timeout);
externalSetOpen?.(true);
setOpen(true);
}}
onMouseLeave={() => {
if (!hoverOpen) return;
timeout = setTimeout(() => {
externalSetOpen?.(false);
setOpen(false);
}, debounce);
}}
onBlur={() => {
window.clearTimeout(timeout);
}}
ref={dropdownRef}
>
<div
onClick={() => {
externalSetOpen?.(!open);
setOpen(!open);
}}
className={twMerge(
"cursor-pointer",
"twui-dropdown-target",
targetWrapperProps?.className
)}
>
{target}
</div>
<div
{...contentWrapperProps}
className={twMerge(
"absolute z-10",
position == "left"
? "left-0"
: position == "right"
? "right-0"
: "",
open ? "flex" : "hidden",
"twui-dropdown-content",
contentWrapperProps?.className
)}
onMouseEnter={() => {
if (!hoverOpen) return;
window.clearTimeout(timeout);
}}
onBlur={() => {
if (!hoverOpen) return;
window.clearTimeout(timeout);
}}
style={{
top: `calc(100% + ${topOffset || 0}px)`,
...contentWrapperProps?.style,
}}
>
{props.children}
</div>
</div>
);
}
+4 -1
View File
@@ -8,6 +8,7 @@ type Props = DetailedHTMLProps<
HTMLDivElement
> & {
target: React.ReactNode;
targetRef?: React.MutableRefObject<HTMLDivElement | undefined>;
};
/**
@@ -15,11 +16,12 @@ type Props = DetailedHTMLProps<
* @className_wrapper twui-modal-root
* @className_wrapper twui-modal
*/
export default function Modal({ target, ...props }: Props) {
export default function Modal({ target, targetRef, ...props }: Props) {
const [wrapper, setWrapper] = React.useState<HTMLDivElement | null>(null);
React.useEffect(() => {
const wrapperEl = document.createElement("div");
wrapperEl.className = twMerge(
"fixed z-[200000] top-0 left-0 w-screen h-screen",
"flex flex-col items-center justify-center",
@@ -57,6 +59,7 @@ export default function Modal({ target, ...props }: Props) {
const root = createRoot(wrapper);
root.render(modalEl);
}}
ref={targetRef as any}
>
{target}
</div>
+1 -1
View File
@@ -20,7 +20,7 @@ export default function Paper({
<div
{...props}
className={twMerge(
"flex flex-row items-center p-4 rounded bg-white dark:bg-white/10",
"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",
"twui-paper",
props.className
+5 -12
View File
@@ -1,5 +1,5 @@
import { twMerge } from "tailwind-merge";
import Input from "../form/Input";
import Input, { InputProps } from "../form/Input";
import Button from "../layout/Button";
import Row from "../layout/Row";
import { Search as SearchIcon } from "lucide-react";
@@ -11,20 +11,13 @@ import React, {
let timeout: any;
export type SearchProps = DetailedHTMLProps<
export type SearchProps<KeyType extends string> = DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
dispatch?: (value?: string) => void;
delay?: number;
inputProps?: DetailedHTMLProps<
InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
> &
DetailedHTMLProps<
TextareaHTMLAttributes<HTMLTextAreaElement>,
HTMLTextAreaElement
>;
inputProps?: InputProps<KeyType>;
buttonProps?: DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
@@ -37,13 +30,13 @@ export type SearchProps = DetailedHTMLProps<
* @className_circle twui-search-input
* @className_circle twui-search-button
*/
export default function Search({
export default function Search<KeyType extends string>({
dispatch,
delay = 500,
inputProps,
buttonProps,
...props
}: SearchProps) {
}: SearchProps<KeyType>) {
const [input, setInput] = React.useState("");
React.useEffect(() => {
+164
View File
@@ -0,0 +1,164 @@
import { LucideProps, Star } from "lucide-react";
import React, {
DetailedHTMLProps,
ForwardRefExoticComponent,
HTMLAttributes,
RefAttributes,
} from "react";
import { twMerge } from "tailwind-merge";
type StarProps = {
total?: number;
value?: number;
size?: number;
starProps?: LucideProps;
allowRating?: boolean;
setValueExternal?: React.Dispatch<React.SetStateAction<number>>;
};
export type TWUI_STAR_RATING_PROPS = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> &
StarProps;
let timeout: any;
/**
* # Star Rating Component
* @className_wrapper twui-star-rating
*/
export default function StarRating({
total = 5,
value = 0,
size,
starProps,
allowRating,
setValueExternal,
...props
}: TWUI_STAR_RATING_PROPS) {
const totalArray = Array(total).fill(null);
const [finalValue, setFinalValue] = React.useState(value);
const [selectedStarValue, setSelectedStarValue] = React.useState(value);
const starClicked = React.useRef(false);
const sectionHovered = React.useRef(false);
React.useEffect(() => {
window.clearTimeout(timeout);
timeout = setTimeout(() => {
setValueExternal?.(finalValue);
}, 500);
}, [selectedStarValue]);
return (
<div
{...props}
className={twMerge(
"flex flex-row items-center gap-0 -ml-[2px]",
"twui-star-rating",
props.className
)}
onMouseEnter={() => {
sectionHovered.current = true;
}}
onMouseLeave={() => {
sectionHovered.current = false;
}}
>
{totalArray.map((_, index) => {
return (
<StarComponent
{...{
total,
value,
size,
starProps,
index,
allowRating,
finalValue,
setFinalValue,
starClicked,
selectedStarValue,
sectionHovered,
setSelectedStarValue,
}}
key={index}
/>
);
})}
</div>
);
}
function StarComponent({
value = 0,
size = 20,
starProps,
index,
allowRating,
finalValue,
setFinalValue,
starClicked,
sectionHovered,
setSelectedStarValue,
selectedStarValue,
}: StarProps & {
index: number;
finalValue: number;
setFinalValue: React.Dispatch<React.SetStateAction<number>>;
setSelectedStarValue: React.Dispatch<React.SetStateAction<number>>;
starClicked: React.MutableRefObject<boolean>;
sectionHovered: React.MutableRefObject<boolean>;
selectedStarValue: number;
}) {
const isActive = index < finalValue;
return (
<div
className={twMerge("p-[2px]", allowRating && "cursor-pointer")}
onMouseEnter={() => {
if (!allowRating) return;
setFinalValue(index + 1);
}}
onMouseLeave={() => {
if (!allowRating) return;
setTimeout(() => {
if (sectionHovered.current) {
return;
}
if (!starClicked.current) {
setFinalValue(0);
}
if (selectedStarValue) {
setFinalValue(selectedStarValue);
}
}, 200);
}}
onClick={() => {
if (!allowRating) return;
starClicked.current = true;
setSelectedStarValue(index + 1);
}}
>
<Star
size={size}
className={twMerge(
"text-slate-300 dark:text-white/20",
isActive &&
"text-orange-500 dark:text-orange-400 fill-orange-500 dark:fill-orange-400",
// allowRating &&
// "hover:text-orange-500 hover:dark:text-orange-400 hover:fill-orange-500 hover:dark:fill-orange-400",
starProps?.className
)}
{...starProps}
/>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
import React, { DetailedHTMLProps, HTMLAttributes } 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";
export type TWUITabsObject = {
title: string;
value: string;
content: React.ReactNode;
defaultActive?: boolean;
};
export type TWUI_TOGGLE_PROPS = React.ComponentProps<typeof Stack> & {
tabsContentArray: TWUITabsObject[];
tabsBorderProps?: React.ComponentProps<typeof Border>;
tabsButtonsWrapperProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
centered?: boolean;
debounce?: number;
};
/**
* # Tabs Component
* @className twui-tabs-wrapper
* @className twui-tab-buttons
* @className twui-tab-button-active
* @className twui-tab-buttons-wrapper
*/
export default function Tabs({
tabsContentArray,
tabsBorderProps,
tabsButtonsWrapperProps,
centered,
debounce = 100,
...props
}: TWUI_TOGGLE_PROPS) {
const values = tabsContentArray.map((obj) => obj.value);
const [activeValue, setActiveValue] = React.useState(
tabsContentArray.find((ctn) => ctn.defaultActive)?.value ||
values[0] ||
undefined
);
const targetContent = tabsContentArray.find(
(ctn) => ctn.value == activeValue
);
return (
<Stack
{...props}
className={twMerge("w-full", "twui-tabs-wrapper", props.className)}
>
<div
{...tabsButtonsWrapperProps}
className={twMerge(
"w-full",
"twui-tab-buttons-wrapper",
tabsButtonsWrapperProps?.className
)}
>
<Border className="p-0 w-full" {...tabsBorderProps}>
<Row
className={twMerge(
"gap-0 items-stretch w-full",
centered && "justify-center"
)}
>
{values.map((value, index) => {
const targetObject = tabsContentArray.find(
(ctn) => ctn.value == value
);
const isActive = value == activeValue;
return (
<span
className={twMerge(
"px-6 py-2 rounded -ml-[1px]",
isActive
? "bg-blue-500 text-white outline-none twui-tab-button-active"
: "text-slate-400 dark:text-white/40 hover:text-slate-800 dark:hover:text-white" +
" cursor-pointer",
"twui-tab-buttons"
)}
onClick={() => {
setActiveValue(undefined);
setTimeout(() => {
setActiveValue(value);
}, debounce);
}}
key={index}
>
{targetObject?.title}
</span>
);
})}
</Row>
</Border>
</div>
{targetContent?.content}
</Stack>
);
}
+75
View File
@@ -0,0 +1,75 @@
import React, { PropsWithChildren } from "react";
import { twMerge } from "tailwind-merge";
export type TWUITabsObject = {
title: string;
value: string;
content: React.ReactNode;
defaultActive?: boolean;
};
export type TWUI_TOGGLE_PROPS = PropsWithChildren &
React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
color?: "normal" | "secondary" | "error" | "success" | "gray";
variant?: "normal" | "outlined" | "ghost";
};
/**
* # Tabs Component
* @className twui-tag
*/
export default function Tag({
color,
variant,
children,
...props
}: TWUI_TOGGLE_PROPS) {
return (
<div
{...props}
className={twMerge(
"text-xs px-2 py-0.5 rounded-full outline outline-0",
color == "secondary"
? "bg-violet-600 outline-violet-600"
: color == "success"
? "bg-emerald-700 outline-emerald-700"
: 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",
variant == "outlined"
? "!bg-transparent outline-1 " +
(color == "secondary"
? "text-violet-600"
: color == "success"
? "text-emerald-700 dark:text-emerald-400"
: color == "error"
? "text-orange-700"
: color == "gray"
? "text-slate-700 dark:text-white/80"
: "text-blue-600")
: variant == "ghost"
? "!bg-transparent outline-none border-none " +
(color == "secondary"
? "text-violet-600"
: color == "success"
? "text-emerald-700 dark:text-emerald-400"
: color == "error"
? "text-orange-700"
: color == "gray"
? "text-slate-700 dark:text-white/80"
: "text-blue-600")
: "",
"twui-tag",
props.className
)}
>
{children}
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
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 Span from "../layout/Span";
export const ToastStyles = ["normal", "success", "error"] as const;
export const ToastColors = ToastStyles;
type Props = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
open?: boolean;
setOpen?: React.Dispatch<React.SetStateAction<boolean>>;
closeDelay?: number;
color?: (typeof ToastStyles)[number];
};
/**
* # Toast Component
* @className twui-toast-root
* @className twui-toast
* @className twui-toast-success
* @className twui-toast-error
*/
export default function Toast({
open,
setOpen,
closeDelay = 4000,
color,
...props
}: Props) {
if (!open) return null;
const toastEl = (
<Card
{...props}
className={twMerge(
"pl-6 pr-8 py-4 bg-blue-700 dark:bg-blue-800",
color == "success"
? "bg-emerald-600 dark:bg-emerald-700 twui-toast-success"
: color == "error"
? "bg-orange-600 dark:bg-orange-700 twui-toast-error"
: "",
props.className,
"twui-toast"
)}
>
<Span
className={twMerge(
"absolute top-2 right-2 z-[100] cursor-pointer"
)}
onClick={(e) => {
const targetEl = e.target as HTMLElement;
const rootWrapperEl = targetEl.closest(".twui-toast-root");
if (rootWrapperEl) {
rootWrapperEl.parentElement?.removeChild(rootWrapperEl);
setOpen?.(false);
}
}}
>
<X size={15} />
</Span>
{props.children}
</Card>
);
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);
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);
}