ui-lib.tsx 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  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. defaultMax?: boolean;
  91. onClose?: () => void;
  92. }
  93. export function Modal(props: ModalProps) {
  94. useEffect(() => {
  95. const onKeyDown = (e: KeyboardEvent) => {
  96. if (e.key === "Escape") {
  97. props.onClose?.();
  98. }
  99. };
  100. window.addEventListener("keydown", onKeyDown);
  101. return () => {
  102. window.removeEventListener("keydown", onKeyDown);
  103. };
  104. // eslint-disable-next-line react-hooks/exhaustive-deps
  105. }, []);
  106. const [isMax, setMax] = useState(!!props.defaultMax);
  107. return (
  108. <div
  109. className={
  110. styles["modal-container"] + ` ${isMax && styles["modal-container-max"]}`
  111. }
  112. >
  113. <div className={styles["modal-header"]}>
  114. <div className={styles["modal-title"]}>{props.title}</div>
  115. <div className={styles["modal-header-actions"]}>
  116. <div
  117. className={styles["modal-header-action"]}
  118. onClick={() => setMax(!isMax)}
  119. >
  120. {isMax ? <MinIcon /> : <MaxIcon />}
  121. </div>
  122. <div
  123. className={styles["modal-header-action"]}
  124. onClick={props.onClose}
  125. >
  126. <CloseIcon />
  127. </div>
  128. </div>
  129. </div>
  130. <div className={styles["modal-content"]}>{props.children}</div>
  131. <div className={styles["modal-footer"]}>
  132. <div className={styles["modal-actions"]}>
  133. {props.actions?.map((action, i) => (
  134. <div key={i} className={styles["modal-action"]}>
  135. {action}
  136. </div>
  137. ))}
  138. </div>
  139. </div>
  140. </div>
  141. );
  142. }
  143. export function showModal(props: ModalProps) {
  144. const div = document.createElement("div");
  145. div.className = "modal-mask";
  146. document.body.appendChild(div);
  147. const root = createRoot(div);
  148. const closeModal = () => {
  149. props.onClose?.();
  150. root.unmount();
  151. div.remove();
  152. };
  153. div.onclick = (e) => {
  154. if (e.target === div) {
  155. closeModal();
  156. }
  157. };
  158. root.render(<Modal {...props} onClose={closeModal}></Modal>);
  159. }
  160. export type ToastProps = {
  161. content: string;
  162. action?: {
  163. text: string;
  164. onClick: () => void;
  165. };
  166. onClose?: () => void;
  167. };
  168. export function Toast(props: ToastProps) {
  169. return (
  170. <div className={styles["toast-container"]}>
  171. <div className={styles["toast-content"]}>
  172. <span>{props.content}</span>
  173. {props.action && (
  174. <button
  175. onClick={() => {
  176. props.action?.onClick?.();
  177. props.onClose?.();
  178. }}
  179. className={styles["toast-action"]}
  180. >
  181. {props.action.text}
  182. </button>
  183. )}
  184. </div>
  185. </div>
  186. );
  187. }
  188. export function showToast(
  189. content: string,
  190. action?: ToastProps["action"],
  191. delay = 3000,
  192. ) {
  193. const div = document.createElement("div");
  194. div.className = styles.show;
  195. document.body.appendChild(div);
  196. const root = createRoot(div);
  197. const close = () => {
  198. div.classList.add(styles.hide);
  199. setTimeout(() => {
  200. root.unmount();
  201. div.remove();
  202. }, 300);
  203. };
  204. setTimeout(() => {
  205. close();
  206. }, delay);
  207. root.render(<Toast content={content} action={action} onClose={close} />);
  208. }
  209. export type InputProps = React.HTMLProps<HTMLTextAreaElement> & {
  210. autoHeight?: boolean;
  211. rows?: number;
  212. };
  213. export function Input(props: InputProps) {
  214. return (
  215. <textarea
  216. {...props}
  217. className={`${styles["input"]} ${props.className}`}
  218. ></textarea>
  219. );
  220. }
  221. export function PasswordInput(props: HTMLProps<HTMLInputElement>) {
  222. const [visible, setVisible] = useState(false);
  223. function changeVisibility() {
  224. setVisible(!visible);
  225. }
  226. return (
  227. <div className={"password-input-container"}>
  228. <IconButton
  229. icon={visible ? <EyeIcon /> : <EyeOffIcon />}
  230. onClick={changeVisibility}
  231. className={"password-eye"}
  232. />
  233. <input
  234. {...props}
  235. type={visible ? "text" : "password"}
  236. className={"password-input"}
  237. />
  238. </div>
  239. );
  240. }
  241. export function Select(
  242. props: React.DetailedHTMLProps<
  243. React.SelectHTMLAttributes<HTMLSelectElement>,
  244. HTMLSelectElement
  245. >,
  246. ) {
  247. const { className, children, ...otherProps } = props;
  248. return (
  249. <div className={`${styles["select-with-icon"]} ${className}`}>
  250. <select className={styles["select-with-icon-select"]} {...otherProps}>
  251. {children}
  252. </select>
  253. <DownIcon className={styles["select-with-icon-icon"]} />
  254. </div>
  255. );
  256. }
  257. export function showConfirm(content: any) {
  258. const div = document.createElement("div");
  259. div.className = "modal-mask";
  260. document.body.appendChild(div);
  261. const root = createRoot(div);
  262. const closeModal = () => {
  263. root.unmount();
  264. div.remove();
  265. };
  266. return new Promise<boolean>((resolve) => {
  267. root.render(
  268. <Modal
  269. title={Locale.UI.Confirm}
  270. actions={[
  271. <IconButton
  272. key="cancel"
  273. text={Locale.UI.Cancel}
  274. onClick={() => {
  275. resolve(false);
  276. closeModal();
  277. }}
  278. icon={<CancelIcon />}
  279. tabIndex={0}
  280. bordered
  281. shadow
  282. ></IconButton>,
  283. <IconButton
  284. key="confirm"
  285. text={Locale.UI.Confirm}
  286. type="primary"
  287. onClick={() => {
  288. resolve(true);
  289. closeModal();
  290. }}
  291. icon={<ConfirmIcon />}
  292. tabIndex={0}
  293. autoFocus
  294. bordered
  295. shadow
  296. ></IconButton>,
  297. ]}
  298. onClose={closeModal}
  299. >
  300. {content}
  301. </Modal>,
  302. );
  303. });
  304. }
  305. function PromptInput(props: {
  306. value: string;
  307. onChange: (value: string) => void;
  308. rows?: number;
  309. }) {
  310. const [input, setInput] = useState(props.value);
  311. const onInput = (value: string) => {
  312. props.onChange(value);
  313. setInput(value);
  314. };
  315. return (
  316. <textarea
  317. className={styles["modal-input"]}
  318. autoFocus
  319. value={input}
  320. onInput={(e) => onInput(e.currentTarget.value)}
  321. rows={props.rows ?? 3}
  322. ></textarea>
  323. );
  324. }
  325. export function showPrompt(content: any, value = "", rows = 3) {
  326. const div = document.createElement("div");
  327. div.className = "modal-mask";
  328. document.body.appendChild(div);
  329. const root = createRoot(div);
  330. const closeModal = () => {
  331. root.unmount();
  332. div.remove();
  333. };
  334. return new Promise<string>((resolve) => {
  335. let userInput = "";
  336. root.render(
  337. <Modal
  338. title={content}
  339. actions={[
  340. <IconButton
  341. key="cancel"
  342. text={Locale.UI.Cancel}
  343. onClick={() => {
  344. closeModal();
  345. }}
  346. icon={<CancelIcon />}
  347. bordered
  348. shadow
  349. tabIndex={0}
  350. ></IconButton>,
  351. <IconButton
  352. key="confirm"
  353. text={Locale.UI.Confirm}
  354. type="primary"
  355. onClick={() => {
  356. resolve(userInput);
  357. closeModal();
  358. }}
  359. icon={<ConfirmIcon />}
  360. bordered
  361. shadow
  362. tabIndex={0}
  363. ></IconButton>,
  364. ]}
  365. onClose={closeModal}
  366. >
  367. <PromptInput
  368. onChange={(val) => (userInput = val)}
  369. value={value}
  370. rows={rows}
  371. ></PromptInput>
  372. </Modal>,
  373. );
  374. });
  375. }