ui-lib.tsx 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. import styles from "./ui-lib.module.scss";
  2. import LoadingIcon from "../icons/three-dots.svg";
  3. import CloseIcon from "../icons/close.svg";
  4. import EyeIcon from "../icons/eye.svg";
  5. import EyeOffIcon from "../icons/eye-off.svg";
  6. import { createRoot } from "react-dom/client";
  7. import React, { HTMLProps, useEffect, useState } from "react";
  8. import { IconButton } from "./button";
  9. export function Popover(props: {
  10. children: JSX.Element;
  11. content: JSX.Element;
  12. open?: boolean;
  13. onClose?: () => void;
  14. }) {
  15. return (
  16. <div className={styles.popover}>
  17. {props.children}
  18. {props.open && (
  19. <div className={styles["popover-content"]}>
  20. <div className={styles["popover-mask"]} onClick={props.onClose}></div>
  21. {props.content}
  22. </div>
  23. )}
  24. </div>
  25. );
  26. }
  27. export function Card(props: { children: JSX.Element[]; className?: string }) {
  28. return (
  29. <div className={styles.card + " " + props.className}>{props.children}</div>
  30. );
  31. }
  32. export function ListItem(props: {
  33. title: string;
  34. subTitle?: string;
  35. children?: JSX.Element | JSX.Element[];
  36. icon?: JSX.Element;
  37. className?: string;
  38. }) {
  39. return (
  40. <div className={styles["list-item"] + ` ${props.className}`}>
  41. <div className={styles["list-header"]}>
  42. {props.icon && <div className={styles["list-icon"]}>{props.icon}</div>}
  43. <div className={styles["list-item-title"]}>
  44. <div>{props.title}</div>
  45. {props.subTitle && (
  46. <div className={styles["list-item-sub-title"]}>
  47. {props.subTitle}
  48. </div>
  49. )}
  50. </div>
  51. </div>
  52. {props.children}
  53. </div>
  54. );
  55. }
  56. export function List(props: {
  57. children:
  58. | Array<JSX.Element | null | undefined>
  59. | JSX.Element
  60. | null
  61. | undefined;
  62. }) {
  63. return <div className={styles.list}>{props.children}</div>;
  64. }
  65. export function Loading() {
  66. return (
  67. <div
  68. style={{
  69. height: "100vh",
  70. width: "100vw",
  71. display: "flex",
  72. alignItems: "center",
  73. justifyContent: "center",
  74. }}
  75. >
  76. <LoadingIcon />
  77. </div>
  78. );
  79. }
  80. interface ModalProps {
  81. title: string;
  82. children?: JSX.Element | JSX.Element[];
  83. actions?: JSX.Element[];
  84. onClose?: () => void;
  85. }
  86. export function Modal(props: ModalProps) {
  87. useEffect(() => {
  88. const onKeyDown = (e: KeyboardEvent) => {
  89. if (e.key === "Escape") {
  90. props.onClose?.();
  91. }
  92. };
  93. window.addEventListener("keydown", onKeyDown);
  94. return () => {
  95. window.removeEventListener("keydown", onKeyDown);
  96. };
  97. // eslint-disable-next-line react-hooks/exhaustive-deps
  98. }, []);
  99. return (
  100. <div className={styles["modal-container"]}>
  101. <div className={styles["modal-header"]}>
  102. <div className={styles["modal-title"]}>{props.title}</div>
  103. <div className={styles["modal-close-btn"]} onClick={props.onClose}>
  104. <CloseIcon />
  105. </div>
  106. </div>
  107. <div className={styles["modal-content"]}>{props.children}</div>
  108. <div className={styles["modal-footer"]}>
  109. <div className={styles["modal-actions"]}>
  110. {props.actions?.map((action, i) => (
  111. <div key={i} className={styles["modal-action"]}>
  112. {action}
  113. </div>
  114. ))}
  115. </div>
  116. </div>
  117. </div>
  118. );
  119. }
  120. export function showModal(props: ModalProps) {
  121. const div = document.createElement("div");
  122. div.className = "modal-mask";
  123. document.body.appendChild(div);
  124. const root = createRoot(div);
  125. const closeModal = () => {
  126. props.onClose?.();
  127. root.unmount();
  128. div.remove();
  129. };
  130. div.onclick = (e) => {
  131. if (e.target === div) {
  132. closeModal();
  133. }
  134. };
  135. root.render(<Modal {...props} onClose={closeModal}></Modal>);
  136. }
  137. export type ToastProps = {
  138. content: string;
  139. action?: {
  140. text: string;
  141. onClick: () => void;
  142. };
  143. onClose?: () => void;
  144. };
  145. export function Toast(props: ToastProps) {
  146. return (
  147. <div className={styles["toast-container"]}>
  148. <div className={styles["toast-content"]}>
  149. <span>{props.content}</span>
  150. {props.action && (
  151. <button
  152. onClick={() => {
  153. props.action?.onClick?.();
  154. props.onClose?.();
  155. }}
  156. className={styles["toast-action"]}
  157. >
  158. {props.action.text}
  159. </button>
  160. )}
  161. </div>
  162. </div>
  163. );
  164. }
  165. export function showToast(
  166. content: string,
  167. action?: ToastProps["action"],
  168. delay = 3000,
  169. ) {
  170. const div = document.createElement("div");
  171. div.className = styles.show;
  172. document.body.appendChild(div);
  173. const root = createRoot(div);
  174. const close = () => {
  175. div.classList.add(styles.hide);
  176. setTimeout(() => {
  177. root.unmount();
  178. div.remove();
  179. }, 300);
  180. };
  181. setTimeout(() => {
  182. close();
  183. }, delay);
  184. root.render(<Toast content={content} action={action} onClose={close} />);
  185. }
  186. export type InputProps = React.HTMLProps<HTMLTextAreaElement> & {
  187. autoHeight?: boolean;
  188. rows?: number;
  189. };
  190. export function Input(props: InputProps) {
  191. return (
  192. <textarea
  193. {...props}
  194. className={`${styles["input"]} ${props.className}`}
  195. ></textarea>
  196. );
  197. }
  198. export function PasswordInput(props: HTMLProps<HTMLInputElement>) {
  199. const [visible, setVisible] = useState(false);
  200. function changeVisibility() {
  201. setVisible(!visible);
  202. }
  203. return (
  204. <div className={"password-input-container"}>
  205. <IconButton
  206. icon={visible ? <EyeIcon /> : <EyeOffIcon />}
  207. onClick={changeVisibility}
  208. className={"password-eye"}
  209. />
  210. <input
  211. {...props}
  212. type={visible ? "text" : "password"}
  213. className={"password-input"}
  214. />
  215. </div>
  216. );
  217. }