This commit is contained in:
2025-11-03 12:24:01 +02:00
commit 0806865287
177 changed files with 18453 additions and 0 deletions

View 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"
}
}

View 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>
);
}

View 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>
);
};

View 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>
</>
);
};

View 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>
);
}

View 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>;
}

View 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>
);
}

View 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,
})}
/>
);
}

View 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>
)}
</>
);
};

View 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;

View 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>
);
}

View 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>
);
}

View 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;
}
};

View 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 };
}

View 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;

View 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>
);
}}
/>
);
}

View 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);

View File

@@ -0,0 +1,4 @@
export interface SelectInputValue {
label: string;
value: string | number | boolean;
}

View 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>
)}
/>
);
}

View File

View 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>
)}
</>
);
}

View 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)} />;
}

View File

@@ -0,0 +1,3 @@
export default function TableBody({ children }: React.PropsWithChildren) {
return <div className="flex-1 flex flex-col">{children}</div>;
};

View 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>;
}

View 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>
);
};

View 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>
);
}

View 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;

View 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;

View 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",
},
};

View 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,
};

View 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));
}

View 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"]
}