ui-lib.tsx 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  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 DownIcon from "../icons/down.svg";
  7. import ConfirmIcon from "../icons/confirm.svg";
  8. import CancelIcon from "../icons/cancel.svg";
  9. import MaxIcon from "../icons/max.svg";
  10. import MinIcon from "../icons/min.svg";
  11. import Locale from "../locales";
  12. import { createRoot } from "react-dom/client";
  13. import React, { HTMLProps, useEffect, useState } from "react";
  14. import { IconButton } from "./button";
  15. export function Popover(props: {
  16. children: JSX.Element;
  17. content: JSX.Element;
  18. open?: boolean;
  19. onClose?: () => void;
  20. }) {
  21. return (
  22. <div className={styles.popover}>
  23. {props.children}
  24. {props.open && (
  25. <div className={styles["popover-content"]}>
  26. <div className={styles["popover-mask"]} onClick={props.onClose}></div>
  27. {props.content}
  28. </div>
  29. )}
  30. </div>
  31. );
  32. }
  33. export function Card(props: { children: JSX.Element[]; className?: string }) {
  34. return (
  35. <div className={styles.card + " " + props.className}>{props.children}</div>
  36. );
  37. }
  38. export function ListItem(props: {
  39. title: string;
  40. subTitle?: string;
  41. children?: JSX.Element | JSX.Element[];
  42. icon?: JSX.Element;
  43. className?: string;
  44. }) {
  45. return (
  46. <div className={styles["list-item"] + ` ${props.className || ""}`}>
  47. <div className={styles["list-header"]}>
  48. {props.icon && <div className={styles["list-icon"]}>{props.icon}</div>}
  49. <div className={styles["list-item-title"]}>
  50. <div>{props.title}</div>
  51. {props.subTitle && (
  52. <div className={styles["list-item-sub-title"]}>
  53. {props.subTitle}
  54. </div>
  55. )}
  56. </div>
  57. </div>
  58. {props.children}
  59. </div>
  60. );
  61. }
  62. export function List(props: {
  63. children:
  64. | Array<JSX.Element | null | undefined>
  65. | JSX.Element
  66. | null
  67. | undefined;
  68. }) {
  69. return <div className={styles.list}>{props.children}</div>;
  70. }
  71. export function Loading() {
  72. return (
  73. <div
  74. style={{
  75. height: "100vh",
  76. width: "100vw",
  77. display: "flex",
  78. alignItems: "center",
  79. justifyContent: "center",
  80. }}
  81. >
  82. <LoadingIcon />
  83. </div>
  84. );
  85. }
  86. interface ModalProps {
  87. title: string;
  88. children?: any;
  89. actions?: JSX.Element[];
  90. onClose?: () => void;
  91. }
  92. export function Modal(props: ModalProps) {
  93. useEffect(() => {
  94. const onKeyDown = (e: KeyboardEvent) => {
  95. if (e.key === "Escape") {
  96. props.onClose?.();
  97. }
  98. };
  99. window.addEventListener("keydown", onKeyDown);
  100. return () => {
  101. window.removeEventListener("keydown", onKeyDown);
  102. };
  103. // eslint-disable-next-line react-hooks/exhaustive-deps
  104. }, []);
  105. const [isMax, setMax] = useState(false);
  106. return (
  107. <div
  108. className={
  109. styles["modal-container"] + ` ${isMax && styles["modal-container-max"]}`
  110. }
  111. >
  112. <div className={styles["modal-header"]}>
  113. <div className={styles["modal-title"]}>{props.title}</div>
  114. <div className={styles["modal-header-actions"]}>
  115. <div
  116. className={styles["modal-header-action"]}
  117. onClick={() => setMax(!isMax)}
  118. >
  119. {isMax ? <MinIcon /> : <MaxIcon />}
  120. </div>
  121. <div
  122. className={styles["modal-header-action"]}
  123. onClick={props.onClose}
  124. >
  125. <CloseIcon />
  126. </div>
  127. </div>
  128. </div>
  129. <div className={styles["modal-content"]}>{props.children}</div>
  130. <div className={styles["modal-footer"]}>
  131. <div className={styles["modal-actions"]}>
  132. {props.actions?.map((action, i) => (
  133. <div key={i} className={styles["modal-action"]}>
  134. {action}
  135. </div>
  136. ))}
  137. </div>
  138. </div>
  139. </div>
  140. );
  141. }
  142. export function showModal(props: ModalProps) {
  143. const div = document.createElement("div");
  144. div.className = "modal-mask";
  145. document.body.appendChild(div);
  146. const root = createRoot(div);
  147. const closeModal = () => {
  148. props.onClose?.();
  149. root.unmount();
  150. div.remove();
  151. };
  152. div.onclick = (e) => {
  153. if (e.target === div) {
  154. closeModal();
  155. }
  156. };
  157. root.render(<Modal {...props} onClose={closeModal}></Modal>);
  158. }
  159. export type ToastProps = {
  160. content: string;
  161. action?: {
  162. text: string;
  163. onClick: () => void;
  164. };
  165. onClose?: () => void;
  166. };
  167. export function Toast(props: ToastProps) {
  168. return (
  169. <div className={styles["toast-container"]}>
  170. <div className={styles["toast-content"]}>
  171. <span>{props.content}</span>
  172. {props.action && (
  173. <button
  174. onClick={() => {
  175. props.action?.onClick?.();
  176. props.onClose?.();
  177. }}
  178. className={styles["toast-action"]}
  179. >
  180. {props.action.text}
  181. </button>
  182. )}
  183. </div>
  184. </div>
  185. );
  186. }
  187. export function showToast(
  188. content: string,
  189. action?: ToastProps["action"],
  190. delay = 3000,
  191. ) {
  192. const div = document.createElement("div");
  193. div.className = styles.show;
  194. document.body.appendChild(div);
  195. const root = createRoot(div);
  196. const close = () => {
  197. div.classList.add(styles.hide);
  198. setTimeout(() => {
  199. root.unmount();
  200. div.remove();
  201. }, 300);
  202. };
  203. setTimeout(() => {
  204. close();
  205. }, delay);
  206. root.render(<Toast content={content} action={action} onClose={close} />);
  207. }
  208. export type InputProps = React.HTMLProps<HTMLTextAreaElement> & {
  209. autoHeight?: boolean;
  210. rows?: number;
  211. };
  212. export function Input(props: InputProps) {
  213. return (
  214. <textarea
  215. {...props}
  216. className={`${styles["input"]} ${props.className}`}
  217. ></textarea>
  218. );
  219. }
  220. export function PasswordInput(props: HTMLProps<HTMLInputElement>) {
  221. const [visible, setVisible] = useState(false);
  222. function changeVisibility() {
  223. setVisible(!visible);
  224. }
  225. return (
  226. <div className={"password-input-container"}>
  227. <IconButton
  228. icon={visible ? <EyeIcon /> : <EyeOffIcon />}
  229. onClick={changeVisibility}
  230. className={"password-eye"}
  231. />
  232. <input
  233. {...props}
  234. type={visible ? "text" : "password"}
  235. className={"password-input"}
  236. />
  237. </div>
  238. );
  239. }
  240. export function Select(
  241. props: React.DetailedHTMLProps<
  242. React.SelectHTMLAttributes<HTMLSelectElement>,
  243. HTMLSelectElement
  244. >,
  245. ) {
  246. const { className, children, ...otherProps } = props;
  247. return (
  248. <div className={`${styles["select-with-icon"]} ${className}`}>
  249. <select className={styles["select-with-icon-select"]} {...otherProps}>
  250. {children}
  251. </select>
  252. <DownIcon className={styles["select-with-icon-icon"]} />
  253. </div>
  254. );
  255. }
  256. export function showConfirm(content: any) {
  257. const div = document.createElement("div");
  258. div.className = "modal-mask";
  259. document.body.appendChild(div);
  260. const root = createRoot(div);
  261. const closeModal = () => {
  262. root.unmount();
  263. div.remove();
  264. };
  265. return new Promise<boolean>((resolve) => {
  266. root.render(
  267. <Modal
  268. title={Locale.UI.Confirm}
  269. actions={[
  270. <IconButton
  271. key="cancel"
  272. text={Locale.UI.Cancel}
  273. onClick={() => {
  274. resolve(false);
  275. closeModal();
  276. }}
  277. icon={<CancelIcon />}
  278. tabIndex={0}
  279. bordered
  280. shadow
  281. ></IconButton>,
  282. <IconButton
  283. key="confirm"
  284. text={Locale.UI.Confirm}
  285. type="primary"
  286. onClick={() => {
  287. resolve(true);
  288. closeModal();
  289. }}
  290. icon={<ConfirmIcon />}
  291. tabIndex={0}
  292. autoFocus
  293. bordered
  294. shadow
  295. ></IconButton>,
  296. ]}
  297. onClose={closeModal}
  298. >
  299. {content}
  300. </Modal>,
  301. );
  302. });
  303. }
  304. function PromptInput(props: {
  305. value: string;
  306. onChange: (value: string) => void;
  307. rows?: number;
  308. }) {
  309. const [input, setInput] = useState(props.value);
  310. const onInput = (value: string) => {
  311. props.onChange(value);
  312. setInput(value);
  313. };
  314. return (
  315. <textarea
  316. className={styles["modal-input"]}
  317. autoFocus
  318. value={input}
  319. onInput={(e) => onInput(e.currentTarget.value)}
  320. rows={props.rows ?? 3}
  321. ></textarea>
  322. );
  323. }
  324. export function showPrompt(content: any, value = "", rows = 3) {
  325. const div = document.createElement("div");
  326. div.className = "modal-mask";
  327. document.body.appendChild(div);
  328. const root = createRoot(div);
  329. const closeModal = () => {
  330. root.unmount();
  331. div.remove();
  332. };
  333. return new Promise<string>((resolve) => {
  334. let userInput = "";
  335. root.render(
  336. <Modal
  337. title={content}
  338. actions={[
  339. <IconButton
  340. key="cancel"
  341. text={Locale.UI.Cancel}
  342. onClick={() => {
  343. closeModal();
  344. }}
  345. icon={<CancelIcon />}
  346. bordered
  347. shadow
  348. tabIndex={0}
  349. ></IconButton>,
  350. <IconButton
  351. key="confirm"
  352. text={Locale.UI.Confirm}
  353. type="primary"
  354. onClick={() => {
  355. resolve(userInput);
  356. closeModal();
  357. }}
  358. icon={<ConfirmIcon />}
  359. bordered
  360. shadow
  361. tabIndex={0}
  362. ></IconButton>,
  363. ]}
  364. onClose={closeModal}
  365. >
  366. <PromptInput
  367. onChange={(val) => (userInput = val)}
  368. value={value}
  369. rows={rows}
  370. ></PromptInput>
  371. </Modal>,
  372. );
  373. });
  374. }