updates
This commit is contained in:
32
web/components/package.json
Normal file
32
web/components/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@greatness/components",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@greatness/util": "workspace:*",
|
||||
"@types/node": "24.1.0",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"@carbon/icons-react": "11.63.0",
|
||||
"@radix-ui/react-select": "2.2.5",
|
||||
"lucide-react": "0.525.0",
|
||||
"@hookform/resolvers": "5.2.0",
|
||||
"@floating-ui/react": "0.27.13",
|
||||
"react-jss": "10.10.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-error-boundary": "6.0.0",
|
||||
"react-router-dom": "7.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"tailwind-merge": "3.3.1",
|
||||
"react-loading-skeleton": "3.5.0",
|
||||
"react-hook-form": "7.61.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "30.0.5"
|
||||
}
|
||||
}
|
||||
78
web/components/src/AlertProvider.tsx
Normal file
78
web/components/src/AlertProvider.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { ErrorOutline, CheckmarkFilled } from "@carbon/icons-react";
|
||||
import { noop } from "@greatness/util";
|
||||
|
||||
import { cn } from "@/util/cn";
|
||||
|
||||
import { createContext, useContext, useState } from "react";
|
||||
|
||||
interface IAlert {
|
||||
message?: string;
|
||||
severity?: "success" | "error";
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface IAlertState {
|
||||
addAlert: (alert: IAlert) => void;
|
||||
alerts: IAlert[];
|
||||
}
|
||||
|
||||
const AlertContext = createContext<IAlertState>({
|
||||
addAlert: noop,
|
||||
alerts: [],
|
||||
});
|
||||
|
||||
export const useAlert = (): IAlertState => useContext(AlertContext);
|
||||
|
||||
export default function AlertProvider({
|
||||
children,
|
||||
}: React.PropsWithChildren) {
|
||||
const [alerts, setAlerts] = useState<IAlert[]>([]);
|
||||
|
||||
const dismissAlert = (alertToDismiss: IAlert) =>
|
||||
setAlerts((currentAlerts) =>
|
||||
currentAlerts.filter((currentAlert) => currentAlert !== alertToDismiss),
|
||||
);
|
||||
|
||||
const addAlert = (alert: IAlert) => {
|
||||
setAlerts((prev) => {
|
||||
setTimeout(() => dismissAlert(alert), 10000); // 10 sec alert for now
|
||||
return [...prev, alert];
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertContext.Provider value={{ addAlert, alerts }}>
|
||||
{children}
|
||||
<div className="fixed top-0 right-4 top-4 z-50">
|
||||
{alerts.map((alert, idx) => (
|
||||
<div
|
||||
role="presentation"
|
||||
key={[alert.title, idx].join("-")}
|
||||
onClick={() => dismissAlert(alert)}
|
||||
className={cn("flex items-center justify-between bg-black text-white border-white p-2 cursor-pointer", {
|
||||
"bg-green-500": alert.severity === "success",
|
||||
"bg-red-500": alert.severity === "error",
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-center w-6 h-6">
|
||||
{alert.severity === "error" ? (
|
||||
<ErrorOutline />
|
||||
) : (
|
||||
<CheckmarkFilled />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{alert.title && (
|
||||
<span className="text-xs font-medium">{alert.title}</span>
|
||||
)}
|
||||
<span className="text-xs">{alert.message}</span>
|
||||
</div>
|
||||
{/* <div className="flex items-center justify-center w-6 h-6">
|
||||
<Exit />
|
||||
</div> */}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AlertContext.Provider>
|
||||
);
|
||||
}
|
||||
167
web/components/src/Button.tsx
Normal file
167
web/components/src/Button.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import createUseStyles from "./theme/createUseStyles";
|
||||
|
||||
import { applySpinnerAnimation, spinnerAnimation } from "./loading/Spinner";
|
||||
import { cn } from "./util/cn";
|
||||
|
||||
const useStyles = createUseStyles<{
|
||||
color?: string;
|
||||
disabled: boolean;
|
||||
isLoading: boolean;
|
||||
size: ButtonSize;
|
||||
}>({
|
||||
button: {
|
||||
"&:hover": {
|
||||
backgroundColor: (options) => {
|
||||
if (options.disabled) {
|
||||
return "var(--color-darkGrey)";
|
||||
}
|
||||
switch (options.color) {
|
||||
case "var(--color-white)":
|
||||
return "var(--color-lightGreen)";
|
||||
case "var(--color-grey)":
|
||||
return "var(--color-darkGrey)";
|
||||
case "var(--color-green)":
|
||||
return "var(--color-darkGreen)";
|
||||
case "var(--color-lightRed)":
|
||||
return "var(--color-semiLightRed)";
|
||||
case "var(--color-semiLightRed)":
|
||||
return "var(--color-lightRed)";
|
||||
case "var(--color-black)":
|
||||
return "rgba(255,255,255,0.1)";
|
||||
default:
|
||||
return options.color;
|
||||
}
|
||||
},
|
||||
},
|
||||
backgroundColor: (options) =>
|
||||
options.disabled ? "var(--color-darkGrey)" : options.color,
|
||||
border: (options) => {
|
||||
switch (options.color) {
|
||||
case "var(--color-black)":
|
||||
return "1px solid var(--color-white)";
|
||||
default:
|
||||
return "none";
|
||||
}
|
||||
},
|
||||
borderRadius: 0,
|
||||
cursor: ({ disabled }) => (disabled ? "not-allowed" : "pointer"),
|
||||
padding: ({ size }) => {
|
||||
switch (size) {
|
||||
case ButtonSize.S:
|
||||
return "var(--spacing-xs) var(--spacing-m)";
|
||||
default:
|
||||
case ButtonSize.M:
|
||||
return "var(--spacing-m) var(--spacing-l)";
|
||||
}
|
||||
},
|
||||
},
|
||||
buttonLabel: {
|
||||
color: (options) => {
|
||||
if (options.disabled) {
|
||||
return "var(--color-white)";
|
||||
}
|
||||
switch (options.color) {
|
||||
case "var(--color-white)":
|
||||
return "var(--color-black)";
|
||||
case "var(--color-grey)":
|
||||
return "var(--color-black)";
|
||||
default:
|
||||
return "var(--color-white)";
|
||||
}
|
||||
},
|
||||
fontWeight: 700,
|
||||
},
|
||||
...spinnerAnimation,
|
||||
buttonContent: {
|
||||
display: ({ isLoading }) => (isLoading ? "inline-flex" : "flex"),
|
||||
opacity: ({ isLoading }) => (isLoading ? 0 : 1),
|
||||
},
|
||||
loading: {
|
||||
border: "3px solid var(--color-whiteOpacity)",
|
||||
borderTopColor: "var(--color-white)",
|
||||
height: 25,
|
||||
left: 0,
|
||||
right: 0,
|
||||
width: 25,
|
||||
...applySpinnerAnimation,
|
||||
},
|
||||
});
|
||||
|
||||
export enum ButtonSize {
|
||||
M = "M",
|
||||
S = "S",
|
||||
}
|
||||
|
||||
export default function Button({
|
||||
children,
|
||||
label,
|
||||
className,
|
||||
isDisabled = false,
|
||||
onClick,
|
||||
color = "var(--color-black)",
|
||||
type = "button",
|
||||
isLoading = false,
|
||||
prefix,
|
||||
size = ButtonSize.M,
|
||||
isUppercase = true,
|
||||
...buttonAttributes
|
||||
}: React.PropsWithChildren<{
|
||||
className?: string;
|
||||
color?: string;
|
||||
isDisabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
isUppercase?: boolean;
|
||||
label: React.ReactNode;
|
||||
onClick?: (
|
||||
event: React.MouseEvent<HTMLButtonElement>,
|
||||
) => void | Promise<void>;
|
||||
prefix?: React.ReactNode;
|
||||
size?: ButtonSize;
|
||||
type?: React.ButtonHTMLAttributes<HTMLButtonElement>["type"];
|
||||
style?: React.CSSProperties;
|
||||
} & Partial<Pick<React.ButtonHTMLAttributes<HTMLButtonElement>, "style" | "onMouseEnter" | "onMouseLeave">>>) {
|
||||
const classes = useStyles({
|
||||
color,
|
||||
disabled: isDisabled,
|
||||
isLoading,
|
||||
size,
|
||||
});
|
||||
|
||||
const onClickWrapper = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
if (onClick) {
|
||||
void onClick(e);
|
||||
}
|
||||
},
|
||||
[isDisabled, onClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
// eslint-disable-next-line react/button-has-type
|
||||
type={type}
|
||||
className={cn(classes.button, "relative user-select-none word-break-keep-all outline-none", className)}
|
||||
{...(isDisabled && { disabled: isDisabled })}
|
||||
{...(!isDisabled && onClick && { onClick: onClickWrapper })}
|
||||
{...buttonAttributes}
|
||||
>
|
||||
{isLoading && <div className={cn("margin-auto absolute inline-block border-radius-round", classes.loading)} />}
|
||||
<div className={cn("flex-row items-center", classes.buttonContent)}>
|
||||
{prefix}
|
||||
<span
|
||||
className={cn("flex-1 font-size-s font-letterSpacing-m font-lineHeight-l text-center", classes.buttonLabel, {
|
||||
"text-uppercase": isUppercase,
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
124
web/components/src/Collapsible.tsx
Normal file
124
web/components/src/Collapsible.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { TextUtil } from "@greatness/util";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import ComponentErrorBoundary from "./ComponentErrorBoundary";
|
||||
import createUseStyles from "./theme/createUseStyles";
|
||||
import { cn } from "./util/cn";
|
||||
|
||||
const useStyles = createUseStyles({
|
||||
collapsible: {
|
||||
"& h2, & h3, & h4": {
|
||||
margin: 0,
|
||||
},
|
||||
appearance: "none",
|
||||
},
|
||||
});
|
||||
|
||||
class CollapsibleStorage {
|
||||
static set(blockKey: string, isCollapsed: boolean) {
|
||||
const key = CollapsibleStorage.getKey(blockKey);
|
||||
const value = isCollapsed ? "true" : "false";
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
static isCollapsed(blockKey: string) {
|
||||
const storageValue = localStorage.getItem(CollapsibleStorage.getKey(blockKey));
|
||||
if (storageValue === null) {
|
||||
return true;
|
||||
}
|
||||
return storageValue === "true";
|
||||
}
|
||||
|
||||
static getKey(blockKey: string) {
|
||||
return `is-collapsed-${blockKey}`;
|
||||
}
|
||||
}
|
||||
|
||||
const useCollapsible = ({
|
||||
blockKey: blockKeyProp,
|
||||
title,
|
||||
}: {
|
||||
blockKey?: string;
|
||||
title: string | React.ReactNode;
|
||||
}) => {
|
||||
const blockKey = (() => {
|
||||
if (!TextUtil.isEmpty(blockKeyProp)) {
|
||||
return blockKeyProp!;
|
||||
}
|
||||
if (typeof title === "string") {
|
||||
return title;
|
||||
}
|
||||
throw new Error("Invalid block key");
|
||||
})();
|
||||
|
||||
const [isCollapsed, setIsCollapsed] = useState(
|
||||
CollapsibleStorage.isCollapsed(blockKey),
|
||||
);
|
||||
|
||||
const toggleVisible = useCallback(() => {
|
||||
setIsCollapsed((prev) => {
|
||||
CollapsibleStorage.set(blockKey, !prev);
|
||||
return !prev;
|
||||
});
|
||||
}, [blockKey]);
|
||||
|
||||
const onError = useCallback((error: Error) => {
|
||||
console.error("Error in Collapsible", error);
|
||||
CollapsibleStorage.set(blockKey, true);
|
||||
setIsCollapsed(true);
|
||||
}, [blockKey]);
|
||||
|
||||
return {
|
||||
isCollapsed,
|
||||
toggleVisible,
|
||||
onError,
|
||||
};
|
||||
};
|
||||
|
||||
export default function Collapsible({
|
||||
Title = "h4",
|
||||
blockKey: blockKeyProp,
|
||||
children,
|
||||
color,
|
||||
title,
|
||||
onIsCollapsedChange,
|
||||
}: React.PropsWithChildren<{
|
||||
Title?: "h2" | "h3" | "h4";
|
||||
blockKey?: string;
|
||||
color?: string;
|
||||
title: string | React.ReactNode;
|
||||
onIsCollapsedChange?: (isCollapsed: boolean) => void;
|
||||
}>) {
|
||||
const classes = useStyles();
|
||||
|
||||
const { isCollapsed, toggleVisible, onError } = useCollapsible({
|
||||
blockKey: blockKeyProp,
|
||||
title,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (onIsCollapsedChange) {
|
||||
onIsCollapsedChange(isCollapsed);
|
||||
}
|
||||
}, [isCollapsed, onIsCollapsedChange]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("items-center flex gap-m outline-none cursor-pointer w-full py-s px-m my-m bg-greyOpacity border-none", classes.collapsible)}
|
||||
onClick={toggleVisible}
|
||||
style={{
|
||||
...(!TextUtil.isEmpty(color) && {
|
||||
background: color!,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{typeof title === "string" ? <Title>{title}</Title> : title}
|
||||
</button>
|
||||
<ComponentErrorBoundary onError={onError}>
|
||||
{!isCollapsed && children}
|
||||
</ComponentErrorBoundary>
|
||||
</>
|
||||
);
|
||||
};
|
||||
45
web/components/src/ComponentErrorBoundary.tsx
Normal file
45
web/components/src/ComponentErrorBoundary.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { FallbackProps } from "react-error-boundary";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
function ErrorFallback({ error, resetErrorBoundary, onError }: FallbackProps & { onError?: (error: Error) => void }) {
|
||||
const location = useLocation();
|
||||
|
||||
const [initialLocation] = useState(location.pathname);
|
||||
|
||||
useEffect(() => {
|
||||
if (onError) {
|
||||
onError(error);
|
||||
resetErrorBoundary();
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
||||
if (location.pathname !== initialLocation || !error) {
|
||||
resetErrorBoundary();
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error occurred", error);
|
||||
}
|
||||
}, [error, location.pathname, initialLocation, resetErrorBoundary, onError]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-l">
|
||||
<h1>Something went wrong!</h1>
|
||||
<p>Please try again later.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ComponentErrorBoundary({
|
||||
children,
|
||||
onError,
|
||||
}: React.PropsWithChildren<{
|
||||
onError?: (error: Error) => void;
|
||||
}>) {
|
||||
return (
|
||||
<ErrorBoundary FallbackComponent={(props) => <ErrorFallback {...props} onError={onError} />}>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
8
web/components/src/LayoutWrapper.tsx
Normal file
8
web/components/src/LayoutWrapper.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { useOutlet } from "react-router-dom";
|
||||
|
||||
import ComponentErrorBoundary from "./ComponentErrorBoundary";
|
||||
|
||||
export default function LayoutWrapper() {
|
||||
const outlet = useOutlet();
|
||||
return <ComponentErrorBoundary>{outlet}</ComponentErrorBoundary>;
|
||||
}
|
||||
47
web/components/src/Modal.tsx
Normal file
47
web/components/src/Modal.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import createUseStyles from "./theme/createUseStyles";
|
||||
import { cn } from "./util/cn";
|
||||
|
||||
const useStyles = createUseStyles(({ media }) => ({
|
||||
modal: {
|
||||
[media.md]: {
|
||||
padding: "var(--spacing-m) var(--spacing-xl)",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export const MODAL_BACKDROP_TIMEOUT = 250;
|
||||
|
||||
export default function Modal({
|
||||
children,
|
||||
handleClose,
|
||||
isOpen,
|
||||
}: React.PropsWithChildren<{
|
||||
handleClose: () => void;
|
||||
isOpen: boolean;
|
||||
}>) {
|
||||
const classes = useStyles();
|
||||
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
ref.current?.showModal();
|
||||
} else {
|
||||
ref.current?.close();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
onCancel={handleClose}
|
||||
className={cn("overflow-auto justify-center items-center border-white bg-black py-xxl px-m shadow-none", classes.modal, {
|
||||
["flex"]: isOpen,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
35
web/components/src/StatusIndicator.tsx
Normal file
35
web/components/src/StatusIndicator.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import createUseStyles from "./theme/createUseStyles";
|
||||
import { cn } from "./util/cn";
|
||||
|
||||
const useStyles = createUseStyles({
|
||||
root: {
|
||||
height: 10,
|
||||
width: 10,
|
||||
},
|
||||
});
|
||||
|
||||
export default function StatusIndicator({
|
||||
active = false,
|
||||
positive = false,
|
||||
intermediary = false,
|
||||
negative = false,
|
||||
}: {
|
||||
active?: boolean;
|
||||
intermediary?: boolean;
|
||||
negative?: boolean;
|
||||
positive?: boolean;
|
||||
}) {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("inline-block cursor-pointer border-radius-round", classes.root, {
|
||||
["bg-grey"]: !active && !positive && !intermediary && !negative,
|
||||
["bg-black"]: active,
|
||||
["bg-green"]: positive,
|
||||
["bg-warningOrange"]: intermediary,
|
||||
["bg-lightRed"]: negative,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
71
web/components/src/Tooltip.tsx
Normal file
71
web/components/src/Tooltip.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
autoPlacement,
|
||||
autoUpdate,
|
||||
offset,
|
||||
shift,
|
||||
useFloating,
|
||||
useHover,
|
||||
useInteractions,
|
||||
} from "@floating-ui/react";
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import createUseStyles from "./theme/createUseStyles";
|
||||
import { cn } from "./util/cn";
|
||||
|
||||
const useStyles = createUseStyles({
|
||||
tooltip: {
|
||||
fontWeight: "normal",
|
||||
opacity: 0.9,
|
||||
zIndex: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
export default function Tooltip({
|
||||
children,
|
||||
content,
|
||||
isDisabled = false,
|
||||
offsetDistance = 4,
|
||||
}: React.PropsWithChildren<{
|
||||
content: React.ReactNode;
|
||||
isDisabled?: boolean;
|
||||
offsetDistance?: number;
|
||||
}>) {
|
||||
const classes = useStyles();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
middleware: [offset(offsetDistance), autoPlacement(), shift()],
|
||||
onOpenChange: setIsOpen,
|
||||
open: isOpen && !isDisabled,
|
||||
whileElementsMounted: autoUpdate,
|
||||
});
|
||||
|
||||
const hover = useHover(context);
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([hover]);
|
||||
|
||||
if (isDisabled || content === undefined) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={refs.setReference}
|
||||
{...getReferenceProps()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
className={cn("bg-black border-white border-radius-s text-white font-size-xs font-lineHeight-s py-xxs px-s", classes.tooltip)}
|
||||
{...getFloatingProps()}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
24
web/components/src/confirm/ConfirmContext.ts
Normal file
24
web/components/src/confirm/ConfirmContext.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { noop } from "@greatness/util";
|
||||
import { createContext } from "react";
|
||||
|
||||
export interface IConfirmContextState {
|
||||
show: boolean;
|
||||
text: string | null;
|
||||
}
|
||||
|
||||
interface IConfirmContextActions {
|
||||
closeConfirm: () => void;
|
||||
showConfirm: (text: string) => void;
|
||||
}
|
||||
|
||||
const ConfirmContext = createContext<
|
||||
[IConfirmContextState, IConfirmContextActions]
|
||||
>([
|
||||
{
|
||||
show: false,
|
||||
text: null,
|
||||
},
|
||||
{ closeConfirm: noop, showConfirm: noop },
|
||||
]);
|
||||
|
||||
export default ConfirmContext;
|
||||
67
web/components/src/confirm/ConfirmDialog.tsx
Normal file
67
web/components/src/confirm/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import "react-loading-skeleton/dist/skeleton.css";
|
||||
|
||||
import { TextUtil } from "@greatness/util";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
|
||||
import Button from "../Button";
|
||||
import Modal from "../Modal";
|
||||
import createUseStyles from "../theme/createUseStyles";
|
||||
import { cn } from "../util/cn";
|
||||
|
||||
import useConfirm from "./useConfirm";
|
||||
|
||||
const useStyles = createUseStyles({
|
||||
buttonsWrapper: {
|
||||
"& button": {
|
||||
flex: 1,
|
||||
maxWidth: 125,
|
||||
wordBreak: "keep-all",
|
||||
},
|
||||
},
|
||||
modalButton: {
|
||||
"& > div": {
|
||||
justifyContent: "center",
|
||||
},
|
||||
},
|
||||
modalContent: {
|
||||
width: 350,
|
||||
},
|
||||
});
|
||||
|
||||
export default function ConfirmDialog({
|
||||
confirmText,
|
||||
cancelText,
|
||||
}: {
|
||||
confirmText: string;
|
||||
cancelText: string;
|
||||
}) {
|
||||
const classes = useStyles();
|
||||
const { onCancel, onConfirm, confirmState } = useConfirm();
|
||||
|
||||
return (
|
||||
<Modal isOpen={confirmState.show} handleClose={onCancel}>
|
||||
<div className={cn("flex flex-col", classes.modalContent)}>
|
||||
<h5>
|
||||
{TextUtil.isEmpty(confirmState.text) ? (
|
||||
<Skeleton width={325} height={28} />
|
||||
) : (
|
||||
confirmState.text
|
||||
)}
|
||||
</h5>
|
||||
<div className={cn("flex gap-m justify-center", classes.buttonsWrapper)}>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
label={confirmText}
|
||||
className={classes.modalButton}
|
||||
/>
|
||||
<Button
|
||||
color="var(--color-semiLightRed)"
|
||||
onClick={onCancel}
|
||||
label={cancelText}
|
||||
className={classes.modalButton}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
44
web/components/src/confirm/ConfirmProvider.tsx
Normal file
44
web/components/src/confirm/ConfirmProvider.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useCallback, useMemo, useReducer } from "react";
|
||||
|
||||
import ConfirmContext from "./ConfirmContext";
|
||||
import {
|
||||
HIDE_CONFIRM,
|
||||
initialState,
|
||||
reducer,
|
||||
SHOW_CONFIRM,
|
||||
} from "./ConfirmReducer";
|
||||
|
||||
export default function ConfirmContextProvider({
|
||||
children,
|
||||
}: React.PropsWithChildren) {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
const showConfirm = useCallback((text: string) => {
|
||||
dispatch({
|
||||
payload: {
|
||||
text,
|
||||
},
|
||||
type: SHOW_CONFIRM,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeConfirm = useCallback(() => {
|
||||
dispatch({
|
||||
type: HIDE_CONFIRM,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const actions = useMemo(
|
||||
() => ({
|
||||
closeConfirm,
|
||||
showConfirm,
|
||||
}),
|
||||
[showConfirm, closeConfirm],
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={[state, actions]}>
|
||||
{children}
|
||||
</ConfirmContext.Provider>
|
||||
);
|
||||
}
|
||||
28
web/components/src/confirm/ConfirmReducer.ts
Normal file
28
web/components/src/confirm/ConfirmReducer.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Reducer } from "react";
|
||||
|
||||
import type { IConfirmContextState } from "./ConfirmContext";
|
||||
|
||||
export const SHOW_CONFIRM = "SHOW_CONFIRM";
|
||||
export const HIDE_CONFIRM = "HIDE_CONFIRM";
|
||||
|
||||
export const initialState: IConfirmContextState = {
|
||||
show: false,
|
||||
text: null,
|
||||
};
|
||||
|
||||
export const reducer: Reducer<
|
||||
IConfirmContextState,
|
||||
{ type: "HIDE_CONFIRM" } | { payload: { text: string }; type: "SHOW_CONFIRM" }
|
||||
> = (_, action) => {
|
||||
switch (action.type) {
|
||||
case SHOW_CONFIRM:
|
||||
return {
|
||||
show: true,
|
||||
text: action.payload.text,
|
||||
};
|
||||
case HIDE_CONFIRM:
|
||||
return initialState;
|
||||
default:
|
||||
return initialState;
|
||||
}
|
||||
};
|
||||
38
web/components/src/confirm/useConfirm.ts
Normal file
38
web/components/src/confirm/useConfirm.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useCallback, useContext } from "react";
|
||||
|
||||
import { MODAL_BACKDROP_TIMEOUT } from "../Modal";
|
||||
|
||||
import ConfirmContext from "./ConfirmContext";
|
||||
|
||||
type ResolveCallback = ((value: unknown) => void) | null;
|
||||
|
||||
let resolveCallback: ResolveCallback = null;
|
||||
|
||||
export default function useConfirm() {
|
||||
const [confirmState, { closeConfirm, showConfirm }] =
|
||||
useContext(ConfirmContext);
|
||||
|
||||
const onConfirm = () => {
|
||||
closeConfirm();
|
||||
resolveCallback?.(true);
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
closeConfirm();
|
||||
resolveCallback?.(false);
|
||||
};
|
||||
|
||||
const confirm = useCallback(
|
||||
async (text: string) => {
|
||||
showConfirm(text);
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolveCallback = resolve;
|
||||
}, MODAL_BACKDROP_TIMEOUT);
|
||||
});
|
||||
},
|
||||
[showConfirm],
|
||||
);
|
||||
|
||||
return { confirm, confirmState, onCancel, onConfirm };
|
||||
}
|
||||
76
web/components/src/form/Form.tsx
Normal file
76
web/components/src/form/Form.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { noop } from "@greatness/util";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { useMemo } from "react";
|
||||
import type {
|
||||
DefaultValues,
|
||||
FieldValues,
|
||||
RegisterOptions,
|
||||
UseFormReset,
|
||||
} from "react-hook-form";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export type ResetForm<T extends FieldValues = any> = UseFormReset<T>;
|
||||
export type FormValidation = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
type HandleSubmit<T extends FieldValues> = (values: T) => Promise<void>;
|
||||
|
||||
type FormDefaultValues = Record<
|
||||
string,
|
||||
unknown[] | boolean | number | string | undefined
|
||||
>;
|
||||
export interface IFormProperties<T extends FieldValues = FormDefaultValues> {
|
||||
autocomplete?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
defaultValues?: DefaultValues<T>;
|
||||
handleSubmit?: HandleSubmit<T>;
|
||||
isDisabled?: boolean;
|
||||
validation?: FormValidation;
|
||||
}
|
||||
export interface IFormError {
|
||||
message: string;
|
||||
type: keyof RegisterOptions | "email" | "manual";
|
||||
}
|
||||
|
||||
const Form = <T extends FieldValues>(
|
||||
props: IFormProperties<T>,
|
||||
): React.ReactElement => {
|
||||
const {
|
||||
children,
|
||||
handleSubmit: handleSubmitProp = noop,
|
||||
validation,
|
||||
className,
|
||||
defaultValues,
|
||||
autocomplete,
|
||||
} = props;
|
||||
const methods = useForm<T>({
|
||||
defaultValues,
|
||||
mode: "onBlur",
|
||||
...(validation && {
|
||||
resolver: yupResolver(validation),
|
||||
}),
|
||||
});
|
||||
|
||||
const { handleSubmit } = methods;
|
||||
|
||||
const onSubmit = useMemo(
|
||||
() => handleSubmit(async (values) => handleSubmitProp(values)),
|
||||
[handleSubmit, handleSubmitProp],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
className={className}
|
||||
onSubmit={onSubmit}
|
||||
noValidate
|
||||
autoComplete={autocomplete}
|
||||
>
|
||||
{children}
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Form;
|
||||
82
web/components/src/form/checkbox/CheckboxInput.tsx
Normal file
82
web/components/src/form/checkbox/CheckboxInput.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import createUseStyles from "../../theme/createUseStyles";
|
||||
import { cn } from "../../util/cn";
|
||||
import { Checkmark } from "@carbon/icons-react";
|
||||
|
||||
const useStyles = createUseStyles<{ isChecked?: boolean }>({
|
||||
buttonDisabled: {
|
||||
backgroundColor: ({ isChecked }) => isChecked === true && "var(--color-darkGrey)",
|
||||
},
|
||||
buttonError: {
|
||||
border: "2px solid var(--color-red) !important",
|
||||
},
|
||||
});
|
||||
|
||||
export default function CheckboxInput({
|
||||
children,
|
||||
className,
|
||||
name,
|
||||
label,
|
||||
error = false,
|
||||
}: React.PropsWithChildren<{
|
||||
className?: string;
|
||||
error?: boolean;
|
||||
label?: string | ReactNode;
|
||||
name: string;
|
||||
}>) {
|
||||
const { control, watch } = useFormContext();
|
||||
const isChecked = watch(name) as boolean;
|
||||
const classes = useStyles({ isChecked });
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={(props) => {
|
||||
const {
|
||||
field: { onChange, ...field },
|
||||
} = props;
|
||||
return (
|
||||
<div className={cn("flex", className)}>
|
||||
<input
|
||||
className="hidden absolute"
|
||||
data-testid={`checkbox-input-${name}`}
|
||||
type="text"
|
||||
aria-hidden
|
||||
readOnly
|
||||
{...field}
|
||||
/>
|
||||
{/** biome-ignore lint/a11y/useSemanticElements: <explanation> todo*/}
|
||||
<div
|
||||
className={cn("items-center bg-black border-white cursor-pointer flex justify-center w-l h-l min-w-l min-h-l outline-none", {
|
||||
[classes.buttonError]: error,
|
||||
[cn("border-white cursor-default", classes.buttonDisabled)]: field.disabled,
|
||||
})}
|
||||
onClick={() => {
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
||||
onChange(!field.value);
|
||||
}}
|
||||
onKeyUp={() => {
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
||||
onChange(!field.value);
|
||||
}}
|
||||
data-testid={`checkbox-${name}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{isChecked ? <Checkmark /> : null}
|
||||
</div>
|
||||
<span className="word-break-break-word user-select-none pl-m font-lineHeight-l font-size-s text-white max-w-full">
|
||||
{[null, undefined].includes(label as null)
|
||||
? null
|
||||
: label}
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
73
web/components/src/form/select/SelectInput.tsx
Normal file
73
web/components/src/form/select/SelectInput.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { TextUtil } from "@greatness/util";
|
||||
import { memo, useCallback } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../ui/select";
|
||||
|
||||
import type { SelectInputValue } from "./types";
|
||||
|
||||
const SelectInput: React.FC<{
|
||||
data: SelectInputValue[];
|
||||
isLoading?: boolean;
|
||||
label: string;
|
||||
maxHeight?: number;
|
||||
name: string;
|
||||
popupIcon?: React.ReactNode;
|
||||
// @ts-expect-error check later
|
||||
}> = ({ name, label, data, isLoading = false, maxHeight = 250 }) => {
|
||||
const { setValue, watch, control } = useFormContext();
|
||||
const [currentValue] = watch([name]);
|
||||
|
||||
const onChange = useCallback(
|
||||
(newInputValue: string) => {
|
||||
setValue(name, newInputValue, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
},
|
||||
[name, setValue],
|
||||
);
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={name}
|
||||
render={() => {
|
||||
return (
|
||||
<Select
|
||||
value={currentValue}
|
||||
onValueChange={(newInputValue) => {
|
||||
onChange(newInputValue);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={TextUtil.isEmpty(label) ? "" : label}
|
||||
>
|
||||
{/** biome-ignore lint/suspicious/noDoubleEquals: <explanation> todo*/}
|
||||
{data.find((a) => a.value == currentValue)?.label ?? ""}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{data
|
||||
.filter((a) => a.value !== "")
|
||||
.map((a) => (
|
||||
<SelectItem key={a.value as string} value={a.value as string}>
|
||||
{a.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(SelectInput);
|
||||
4
web/components/src/form/select/types.ts
Normal file
4
web/components/src/form/select/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface SelectInputValue {
|
||||
label: string;
|
||||
value: string | number | boolean;
|
||||
}
|
||||
84
web/components/src/form/text/TextInput.tsx
Normal file
84
web/components/src/form/text/TextInput.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { TextUtil } from "@greatness/util";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import createUseStyles from "../../theme/createUseStyles";
|
||||
import { cn } from "../../util/cn";
|
||||
|
||||
const useStyles = createUseStyles({
|
||||
textField: {
|
||||
"& > input": {
|
||||
"-moz-box-shadow": "none",
|
||||
"-webkit-box-shadow": "none",
|
||||
appearance: "none",
|
||||
backgroundImage: "none",
|
||||
boxShadow: "none",
|
||||
minHeight: 40,
|
||||
},
|
||||
"& > label": {
|
||||
left: 10,
|
||||
top: -5,
|
||||
whiteSpace: "nowrap",
|
||||
width: "min-content",
|
||||
},
|
||||
minHeight: 42,
|
||||
},
|
||||
});
|
||||
|
||||
export default function TextInput({
|
||||
className,
|
||||
label,
|
||||
name,
|
||||
type = "text",
|
||||
isDisabled = false,
|
||||
autoComplete,
|
||||
}: {
|
||||
autoComplete?: string;
|
||||
className?: string;
|
||||
isDisabled?: boolean;
|
||||
label?: string;
|
||||
name: string;
|
||||
type?: "text" | "password" | "email" | "textarea";
|
||||
}) {
|
||||
const classes = useStyles();
|
||||
const {
|
||||
watch,
|
||||
control,
|
||||
} = useFormContext();
|
||||
const currentValue = watch(name);
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
defaultValue={currentValue ?? ""}
|
||||
render={({ field }) => (
|
||||
<div className={cn("flex flex-col flex-1 relative justify-center", classes.textField, className)}>
|
||||
{type === "textarea" ? (
|
||||
<textarea
|
||||
id={name}
|
||||
{...field}
|
||||
disabled={isDisabled}
|
||||
className="border-white text-white outline-none py-xs px-s bg-black"
|
||||
rows={4}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={type}
|
||||
id={name}
|
||||
{...field}
|
||||
disabled={isDisabled}
|
||||
autoComplete={autoComplete}
|
||||
{...(name === "newPassword" && {
|
||||
autoComplete: "new-password",
|
||||
})}
|
||||
className="border-white text-white outline-none py-xs px-s bg-black"
|
||||
/>
|
||||
)}
|
||||
<label htmlFor={name} className="absolute word-break-keep-all bg-black font-size-xxs py-0 px-s text-white">
|
||||
{TextUtil.isEmpty(label) ? "" : label}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
0
web/components/src/index.ts
Normal file
0
web/components/src/index.ts
Normal file
112
web/components/src/loading/LoadingPage.tsx
Normal file
112
web/components/src/loading/LoadingPage.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { TextUtil } from "@greatness/util";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import createUseStyles from "../theme/createUseStyles";
|
||||
|
||||
import Spinner from "./Spinner";
|
||||
import { cn } from "../util/cn";
|
||||
|
||||
const useStyles = createUseStyles<{ containerHeight?: number | string }>({
|
||||
container: {
|
||||
height: ({ containerHeight }) =>
|
||||
containerHeight !== undefined
|
||||
? typeof containerHeight === "number"
|
||||
? `${containerHeight}px`
|
||||
: containerHeight
|
||||
: "100%",
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type ScrollIntoViewRef = React.RefObject<HTMLDivElement | null> | null;
|
||||
type ScrollIntoViewOptions = {
|
||||
behavior?: ScrollBehavior;
|
||||
block?: ScrollLogicalPosition;
|
||||
inline?: ScrollLogicalPosition;
|
||||
narrow?: boolean;
|
||||
} | null;
|
||||
type ScrollIntoViewFunction = (
|
||||
ref?: ScrollIntoViewRef,
|
||||
options?: ScrollIntoViewOptions,
|
||||
) => void;
|
||||
|
||||
const scrollIntoView: ScrollIntoViewFunction = (ref, options) => {
|
||||
if (!ref?.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
let scrollOptions = options;
|
||||
const isWide = window.innerWidth > 768;
|
||||
if (!scrollOptions || (options?.narrow === true && isWide)) {
|
||||
scrollOptions = {
|
||||
behavior: "smooth",
|
||||
};
|
||||
if (isWide) {
|
||||
scrollOptions.block = "center";
|
||||
}
|
||||
}
|
||||
ref.current.scrollIntoView(scrollOptions);
|
||||
};
|
||||
|
||||
export default function LoadingPage({
|
||||
className,
|
||||
isLoading = true,
|
||||
children,
|
||||
containerHeight,
|
||||
scroll,
|
||||
persist = false,
|
||||
label,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
containerHeight?: number | string;
|
||||
isLoading?: boolean;
|
||||
label?: string;
|
||||
persist?: boolean;
|
||||
scroll?: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const classes = useStyles({ containerHeight });
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
if (scroll === true) {
|
||||
scrollIntoView(ref);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isLoading, scroll]);
|
||||
|
||||
const isAnimationFinished = !isLoading;
|
||||
if (isAnimationFinished && !persist) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center bg-white w-auto p-xs",
|
||||
classes.container,
|
||||
className,
|
||||
{
|
||||
["hidden"]: isAnimationFinished,
|
||||
}
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
<Spinner />
|
||||
{!TextUtil.isEmpty(label) && (
|
||||
<div className="pb-m pt-0 text-center word-break-break-word">
|
||||
<h4>{label!}</h4>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{persist && (
|
||||
<div className={isAnimationFinished ? "persist" : "hidden"}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
web/components/src/loading/Spinner.tsx
Normal file
44
web/components/src/loading/Spinner.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { cn } from "../util/cn";
|
||||
import createUseStyles from "../theme/createUseStyles";
|
||||
|
||||
export const spinnerAnimation = {
|
||||
"@keyframes spinner, @-webkit-keyframes spinner": {
|
||||
"0%": {
|
||||
"-webkit-transform": "rotate(0deg)",
|
||||
transform: "rotate(0deg)",
|
||||
},
|
||||
"100%": {
|
||||
"-webkit-transform": "rotate(360deg)",
|
||||
transform: "rotate(360deg)",
|
||||
},
|
||||
},
|
||||
};
|
||||
export const applySpinnerAnimation = {
|
||||
"-webkit-animation": "$spinner 1.1s ease-in-out infinite",
|
||||
animation: "$spinner 1.1s ease-in-out infinite",
|
||||
};
|
||||
|
||||
const useStyles = createUseStyles({
|
||||
...spinnerAnimation,
|
||||
loader: {
|
||||
MsTransform: "translateZ(0)",
|
||||
WebkitTransform: "translateZ(0)",
|
||||
border: "var(--spacing-s) solid var(--color-white)",
|
||||
borderLeftColor: "var(--color-darkGrey)",
|
||||
margin: "var(--spacing-xl) auto",
|
||||
position: "relative",
|
||||
textIndent: "-9999em",
|
||||
transform: "translateZ(0)",
|
||||
...applySpinnerAnimation,
|
||||
"&, &:after": {
|
||||
borderRadius: "50%",
|
||||
height: 80,
|
||||
width: 80,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default function Spinner() {
|
||||
const classes = useStyles();
|
||||
return <div className={cn("font-size-xxxs", classes.loader)} />;
|
||||
}
|
||||
3
web/components/src/table/TableBody.tsx
Normal file
3
web/components/src/table/TableBody.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function TableBody({ children }: React.PropsWithChildren) {
|
||||
return <div className="flex-1 flex flex-col">{children}</div>;
|
||||
};
|
||||
20
web/components/src/table/TableHead.tsx
Normal file
20
web/components/src/table/TableHead.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import createUseStyles from "../theme/createUseStyles";
|
||||
import { cn } from "../util/cn";
|
||||
|
||||
const useStyles = createUseStyles({
|
||||
tableHead: {
|
||||
"& > div": {
|
||||
"&:hover": {
|
||||
backgroundColor: "inherit !important",
|
||||
cursor: "initial !important",
|
||||
},
|
||||
borderBottom: "1px solid var(--color-grey)",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default function TableHead({ children }: React.PropsWithChildren) {
|
||||
const classes = useStyles();
|
||||
|
||||
return <div className={cn("flex-1 flex", classes.tableHead)}>{children}</div>;
|
||||
}
|
||||
55
web/components/src/table/TableRow.tsx
Normal file
55
web/components/src/table/TableRow.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import createUseStyles from "../theme/createUseStyles";
|
||||
import { cn } from "../util/cn";
|
||||
|
||||
export const TABLE_ROW_HEIGHT = 35;
|
||||
|
||||
const useStyles = createUseStyles({
|
||||
tableRow: {
|
||||
"& > div": {
|
||||
"&:first-child": {
|
||||
flexGrow: [3, "!important"],
|
||||
},
|
||||
"&:last-child": {
|
||||
alignContent: "flex-end",
|
||||
},
|
||||
"&:nth-last-child(2)": {
|
||||
flexGrow: [1, "!important"],
|
||||
},
|
||||
alignContent: "center",
|
||||
display: "flex",
|
||||
flex: 1,
|
||||
flexFlow: "column",
|
||||
flexGrow: 2,
|
||||
justifyContent: "center",
|
||||
padding: "var(--spacing-xxs) 0",
|
||||
},
|
||||
"&:hover": {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||
cursor: "pointer",
|
||||
},
|
||||
"&:not(:last-child)": {
|
||||
borderBottom: "1px solid var(--color-grey)",
|
||||
},
|
||||
height: TABLE_ROW_HEIGHT,
|
||||
},
|
||||
});
|
||||
|
||||
export default function TableRow({
|
||||
children,
|
||||
rowClassName,
|
||||
rowProps,
|
||||
}: React.PropsWithChildren<{
|
||||
rowClassName?: string;
|
||||
rowProps?: {
|
||||
onClick: React.MouseEventHandler<HTMLElement>;
|
||||
tabIndex: 0;
|
||||
};
|
||||
}>) {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<div className={cn("flex-1 flex gap-m py-xxs px-s", classes.tableRow, rowClassName)} {...rowProps}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
14
web/components/src/table/TableWrapper.tsx
Normal file
14
web/components/src/table/TableWrapper.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { cn } from "../util/cn";
|
||||
|
||||
export default function TableWrapper({
|
||||
children,
|
||||
tableWrapperClassName,
|
||||
}: React.PropsWithChildren<{
|
||||
tableWrapperClassName?: string;
|
||||
}>) {
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full w-full border-white", tableWrapperClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
web/components/src/theme/createUseStyles.ts
Normal file
16
web/components/src/theme/createUseStyles.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Styles } from "react-jss";
|
||||
import { createUseStyles as ogCreateUseStyles } from "react-jss";
|
||||
|
||||
import type commonTheme from "./index";
|
||||
|
||||
const createUseStyles = <Props>(
|
||||
styles:
|
||||
| Styles<string | number, Props, typeof commonTheme>
|
||||
| ((
|
||||
theme: typeof commonTheme,
|
||||
) => Styles<string | number, Props, undefined>),
|
||||
) => {
|
||||
return ogCreateUseStyles(styles);
|
||||
};
|
||||
|
||||
export default createUseStyles;
|
||||
91
web/components/src/theme/index.ts
Normal file
91
web/components/src/theme/index.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
const theme = {
|
||||
borderRadius: {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
s: 4,
|
||||
m: 8,
|
||||
l: 16,
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
},
|
||||
color: {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
white: "rgba(255, 255, 255, 1)",
|
||||
whiteOpacity: "rgba(255, 255, 255, 0.3)",
|
||||
|
||||
black: "rgba(0, 0, 0, 1)",
|
||||
|
||||
grey: "rgba(238, 238, 238, 1)",
|
||||
darkGrey: "rgba(144, 144, 144, 1)",
|
||||
|
||||
red: "rgba(255, 0, 0, 1)",
|
||||
semiLightRed: "rgba(255, 0, 0, 0.75)",
|
||||
lightRed: "rgba(255, 0, 0, 0.5)",
|
||||
|
||||
lightGreen: "rgba(0, 255, 255, 0.75)",
|
||||
green: "rgba(0, 255, 0, 1)",
|
||||
darkGreen: "rgba(27, 120, 43, 1)",
|
||||
|
||||
warningOrange: "rgba(237, 111, 46, 0.8)",
|
||||
|
||||
yellow: "rgba(255, 255, 0, 0.8)",
|
||||
|
||||
blue: "rgba(0, 0, 255, 1)",
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
},
|
||||
font: {
|
||||
letterSpacing: {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
s: "0.15px",
|
||||
m: "0.32px",
|
||||
l: "0.4px",
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
},
|
||||
lineHeight: {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
s: 1.2,
|
||||
m: 1.4,
|
||||
l: 1.7,
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
},
|
||||
size: {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
xxs: 12,
|
||||
xs: 14,
|
||||
s: 16,
|
||||
m: 18,
|
||||
l: 24,
|
||||
xl: 36,
|
||||
xxl: 48,
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
},
|
||||
},
|
||||
media: {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
xs: "@media screen and (min-width: 396px)",
|
||||
sm: "@media screen and (min-width: 576px)",
|
||||
md: "@media screen and (min-width: 768px)",
|
||||
lg: "@media screen and (min-width: 992px)",
|
||||
xl: "@media screen and (min-width: 1200px)",
|
||||
xxl: "@media screen and (min-width: 1350px)",
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
},
|
||||
sizes: {
|
||||
maxWidth: "@media screen and (min-width: 1440px)",
|
||||
maxWidthValue: 1440,
|
||||
},
|
||||
spacing: {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
xxs: 2,
|
||||
xs: 4,
|
||||
s: 8,
|
||||
m: 16,
|
||||
l: 24,
|
||||
xl: 32,
|
||||
xxl: 40,
|
||||
"3xl": 48,
|
||||
"4xl": 64,
|
||||
"5xl": 128,
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
},
|
||||
};
|
||||
|
||||
export default theme;
|
||||
22
web/components/src/theme/scrollbar.ts
Normal file
22
web/components/src/theme/scrollbar.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export const scrollbarStyles = {
|
||||
"&::-webkit-scrollbar": {
|
||||
background: "#E6E8EC",
|
||||
width: 8,
|
||||
},
|
||||
"&::-webkit-scrollbar-thumb": {
|
||||
"&:hover": {
|
||||
background: "#75787A",
|
||||
},
|
||||
background: "#C3C4C6",
|
||||
},
|
||||
};
|
||||
|
||||
export const horizontalScrollBar = {
|
||||
"&::-webkit-scrollbar": {
|
||||
background: "#E6E8EC",
|
||||
height: 8,
|
||||
},
|
||||
"&::-webkit-scrollbar-thumb": {
|
||||
backgroundColor: "#C3C4C6",
|
||||
},
|
||||
};
|
||||
198
web/components/src/ui/select.tsx
Normal file
198
web/components/src/ui/select.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "../util/cn";
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-2 rounded-none border px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"!bg-black !text-white !border-white",
|
||||
"data-[placeholder]:!text-white",
|
||||
"hover:!bg-neutral-900 focus-visible:!border-white focus-visible:ring-white/30",
|
||||
"aria-invalid:!border-red aria-invalid:ring-red/20",
|
||||
"data-[size=default]:h-10 data-[size=sm]:h-8",
|
||||
"*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"dark:!bg-black dark:!text-white dark:!border-white dark:hover:!bg-neutral-900 data-[placeholder]:dark:!text-white focus-visible:dark:!border-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 !text-white opacity-100" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-none border shadow-md",
|
||||
"!bg-black !text-white !border-white",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-sm text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"py-xs pl-m pr-xxl",
|
||||
"!bg-black !text-white",
|
||||
"focus:!bg-neutral-900 focus:!text-white",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
"[&_svg:not([class*='text-'])]:!text-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
6
web/components/src/util/cn.ts
Normal file
6
web/components/src/util/cn.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
22
web/components/tsconfig.json
Normal file
22
web/components/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"composite": true,
|
||||
"noEmit": false,
|
||||
"jsx": "react-jsx",
|
||||
"types": [
|
||||
"node",
|
||||
"react",
|
||||
"react-dom",
|
||||
"react-router-dom"
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user