@hrnec06/react_utils 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hrnec06/react_utils",
3
- "version": "1.4.2",
3
+ "version": "1.5.0",
4
4
  "description": "A debugger component for react.",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
@@ -26,12 +26,13 @@
26
26
  "typescript": "^5.8.3"
27
27
  },
28
28
  "dependencies": {
29
- "@hrnec06/util": "^1.4.21",
29
+ "@hrnec06/util": "^1.6.1",
30
30
  "clsx": "^2.1.1",
31
31
  "lucide-react": "^0.525.0",
32
32
  "react": "^19.1.0",
33
33
  "react-dom": "^19.2.3",
34
- "uuid": "^13.0.0"
34
+ "uuid": "^13.0.0",
35
+ "zod": "^4.2.1"
35
36
  },
36
37
  "scripts": {
37
38
  "dev": "tsup src/index.tsx --format cjs,esm --dts --watch"
@@ -19,7 +19,7 @@ interface DebugProps {
19
19
  autoHeight?: boolean,
20
20
  openRoot?: boolean
21
21
  }
22
- export default function Debug({
22
+ function Debug({
23
23
  value,
24
24
  openPaths,
25
25
  excludePaths,
@@ -312,4 +312,7 @@ export default function Debug({
312
312
  ),
313
313
  document.body
314
314
  );
315
- }
315
+ }
316
+
317
+ export * as terminal from "./DebuggerTerminal";
318
+ export { Debug };
@@ -1,5 +1,5 @@
1
1
  import clsx from "clsx";
2
- import { ChevronDown } from "lucide-react";
2
+ import { ChevronDown, Terminal } from "lucide-react";
3
3
 
4
4
  export function Char_Colon() {
5
5
  return (
@@ -60,4 +60,9 @@ export function Chevron_Toggle({
60
60
  }
61
61
  </button>
62
62
  );
63
+ }
64
+
65
+ export function Terminal_Icon()
66
+ {
67
+ return ( <Terminal /> );
63
68
  }
@@ -0,0 +1,336 @@
1
+ import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
2
+ import { assert, Optional, ReactUtils } from "@hrnec06/util";
3
+ import useUpdateEffect from "../../hooks/useUpdateEffect";
4
+ import useNamespacedId from "../../hooks/useNamespacedId";
5
+ import z from "zod";
6
+
7
+ const TERMINAL_NAMESPACE = "terminal";
8
+
9
+ enum LogRole {
10
+ Input,
11
+ History
12
+ }
13
+
14
+ enum LogType {
15
+ Info,
16
+ Warning,
17
+ Error,
18
+ }
19
+
20
+ interface LogItem {
21
+ value: unknown,
22
+ role: LogRole,
23
+ type: LogType
24
+ }
25
+
26
+ interface TerminalContext {
27
+ terminal: Terminal,
28
+ parent?: TerminalContext
29
+ }
30
+ const TerminalContext = createContext<Optional<TerminalContext>>(undefined);
31
+
32
+ type OnInputCallback = (command: string, args: string[], terminal: Terminal) => boolean;
33
+
34
+ class Terminal
35
+ {
36
+ public readonly id: string;
37
+ public readonly name: string;
38
+ private readonly onInput?: OnInputCallback;
39
+
40
+ private history: ReactUtils.State<LogItem[]>;
41
+ private inputHistory: ReactUtils.State<string[]>;
42
+
43
+ public parentTerminal?: Terminal;
44
+
45
+ constructor(
46
+ id: string,
47
+ name: string,
48
+ history: ReactUtils.State<LogItem[]>,
49
+ inputHistory: ReactUtils.State<string[]>,
50
+ onInput?: OnInputCallback,
51
+ )
52
+ {
53
+ this.id = id;
54
+ this.name = name;
55
+ this.history = history;
56
+ this.inputHistory = inputHistory;
57
+ this.onInput = onInput;
58
+ }
59
+
60
+ protected addLog(value: unknown, type: LogType, role: LogRole)
61
+ {
62
+ const newEntry = {
63
+ value: value,
64
+ role: role,
65
+ type: type
66
+ };
67
+
68
+ this.history[1](prev => [...prev, newEntry]);
69
+ }
70
+
71
+ public _mount(history: ReactUtils.State<LogItem[]>)
72
+ {
73
+ this.history = history;
74
+ }
75
+
76
+ public getLsKey(): string
77
+ {
78
+ return "terminal." + this.id;
79
+ }
80
+
81
+ public clear(): Terminal
82
+ {
83
+ this.history[1]([]);
84
+
85
+ return this;
86
+ }
87
+
88
+ public log(value: unknown, type: LogType = LogType.Info): Terminal
89
+ {
90
+ this.addLog(value, type, LogRole.History);
91
+
92
+ return this;
93
+ }
94
+
95
+ public getLog(): LogItem[]
96
+ {
97
+ return this.history[0];
98
+ }
99
+
100
+ public getInputHistory(): string[]
101
+ {
102
+ return this.inputHistory[0];
103
+ }
104
+
105
+ public hasInput(): boolean
106
+ {
107
+ return this.onInput !== undefined;
108
+ }
109
+
110
+ public input(value: string): boolean
111
+ {
112
+ if (!this.onInput)
113
+ return false;
114
+
115
+ this.inputHistory[1](prev => [
116
+ ...prev.filter((item) => item !== value),
117
+ value
118
+ ]);
119
+
120
+ this.addLog(value, LogType.Info, LogRole.Input);
121
+
122
+ try
123
+ {
124
+ const args = value.split(" ");
125
+ const command = args.shift();
126
+ assert(typeof command === "string");
127
+
128
+ this.handleCommand(command, args);
129
+
130
+ return true;
131
+ }
132
+ catch (error)
133
+ {
134
+ if (typeof error === "string")
135
+ {
136
+ this.log(error, LogType.Error);
137
+ }
138
+ else if (error instanceof Error)
139
+ {
140
+ this.log(error.message, LogType.Error);
141
+ }
142
+ else
143
+ {
144
+ this.log("Unknown error.", LogType.Error);
145
+ }
146
+
147
+ return false;
148
+ }
149
+ }
150
+
151
+ private handleCommand(command: string, args: string[])
152
+ {
153
+ switch (command)
154
+ {
155
+ case 'clear':
156
+ {
157
+ this.clear();
158
+ this.log("Console was cleared!");
159
+
160
+ return true;
161
+ }
162
+
163
+ case 'json':
164
+ {
165
+ const rest = args.join('');
166
+
167
+ try
168
+ {
169
+ const parsed = JSON.parse(rest);
170
+
171
+ this.log(parsed);
172
+ }
173
+ catch (error)
174
+ {
175
+ assert(error instanceof SyntaxError);
176
+
177
+ throw `Invalid JSON: ${error.message}`;
178
+ }
179
+
180
+ return true;
181
+ }
182
+
183
+ default:
184
+ {
185
+ if (this.onInput && this.onInput(command, args, this))
186
+ {
187
+ return true;
188
+ }
189
+
190
+ throw `Invalid command: ${command}`;
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ interface TerminalContextProviderProps extends React.PropsWithChildren {
197
+ terminal: Terminal
198
+ }
199
+ function TerminalContextProvider({
200
+ terminal,
201
+ children
202
+ }: TerminalContextProviderProps)
203
+ {
204
+ const parent = findTerminalContext();
205
+
206
+ return (
207
+ <TerminalContext.Provider
208
+ value={{
209
+ terminal: terminal,
210
+ parent: parent
211
+ }}
212
+ >
213
+ {children}
214
+ </TerminalContext.Provider>
215
+ )
216
+ }
217
+
218
+ /**
219
+ * Returns the default history for a terminal
220
+ */
221
+ function getDefaultHistory(terminal: Terminal)
222
+ {
223
+ try
224
+ {
225
+ const savedValue = localStorage.getItem(terminal.getLsKey());
226
+
227
+ const savedValueJSON = JSON.parse(savedValue ?? "[]");
228
+
229
+ const schema = z.array(z.object({
230
+ value: z.any(),
231
+ role: z.enum(LogRole),
232
+ type: z.enum(LogType)
233
+ }));
234
+
235
+ const parsed = schema.parse(savedValueJSON);
236
+
237
+ return parsed;
238
+ }
239
+ catch (error)
240
+ {
241
+ localStorage.removeItem(terminal.getLsKey());
242
+ console.error("Terminal data parse failed: ", error);
243
+
244
+ return [];
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Recursively browses the contexts to find a matching terminal
250
+ */
251
+ function matchTerminal(name: string, ctx: TerminalContext): Optional<TerminalContext>
252
+ {
253
+ if (name === ctx.terminal.name)
254
+ return ctx;
255
+
256
+ if (ctx.parent)
257
+ return matchTerminal(name, ctx.parent);
258
+
259
+ return undefined;
260
+ }
261
+
262
+ /**
263
+ * Attempts to find a TerminalContext matching the query
264
+ */
265
+ function findTerminalContext(name?: string): Optional<TerminalContext>
266
+ {
267
+ const ctx = useContext(TerminalContext);
268
+
269
+ if (ctx && name)
270
+ {
271
+ return matchTerminal(name, ctx);
272
+ }
273
+
274
+ return ctx;
275
+ }
276
+
277
+ /**
278
+ * Get terminal context
279
+ */
280
+ function useTerminalContext(name: string, shouldThrow: true): Terminal;
281
+ function useTerminalContext(name?: string, shouldThrow?: false): Optional<Terminal>
282
+ function useTerminalContext(name?: string, shouldThrow: boolean = false): Optional<Terminal>
283
+ {
284
+ const ctx = findTerminalContext(name);
285
+
286
+ if (shouldThrow === true && ctx === undefined)
287
+ {
288
+ if (name)
289
+ throw new Error(`Terminal with the name "${name}" was not found.`);
290
+ else
291
+ throw new Error(`useTerminalContext may be used only within TerminalContext.Provider!`);
292
+ }
293
+
294
+ return ctx?.terminal;
295
+ }
296
+
297
+ /**
298
+ * Initializes a terminal
299
+ */
300
+ function useTerminal(
301
+ name: string,
302
+ onInput?: OnInputCallback,
303
+ ): Terminal
304
+ {
305
+ const id = useNamespacedId(TERMINAL_NAMESPACE);
306
+
307
+ const history = useState<LogItem[]>([]);
308
+ const inputHistory = useState<string[]>([]);
309
+
310
+ const terminal = useMemo(() => {
311
+ return new Terminal(id, name, history, inputHistory, onInput);
312
+ }, [name, history, onInput]);
313
+
314
+ useEffect(() => {
315
+ const def = getDefaultHistory(terminal);
316
+
317
+ history[1](def);
318
+ }, []);
319
+
320
+ useUpdateEffect(() => {
321
+ localStorage.setItem(terminal.getLsKey(), JSON.stringify(history[0]));
322
+ }, [history]);
323
+
324
+ return terminal;
325
+ }
326
+
327
+ export {
328
+ LogRole,
329
+ LogType,
330
+ Terminal,
331
+ TerminalContextProvider,
332
+ useTerminal,
333
+ useTerminalContext,
334
+
335
+ type LogItem,
336
+ };
@@ -1,5 +1,7 @@
1
1
  import useDebugger from "../DebuggerContext";
2
2
  import { matchPath } from "../DebuggerLogic";
3
+ import { Terminal } from "../DebuggerTerminal";
4
+ import DebugTerminal from "./DebugTerminal";
3
5
  import ValueArray from "./ValueArray";
4
6
  import ValueBoolean from "./ValueBoolean";
5
7
  import ValueFunction from "./ValueFunction";
@@ -35,6 +37,9 @@ export default function ParseValue({
35
37
 
36
38
  const shouldBeExpanded = (isRootObject && debug.options.openRoot) || (matchPath(path, debug.paths.open) && !matchPath(path, debug.paths.exclude));
37
39
 
40
+ if (value instanceof Terminal)
41
+ return <DebugTerminal terminal={value} path={path} defaultExpanded={shouldBeExpanded} />
42
+
38
43
  if (Array.isArray(value)) {
39
44
  return <ValueArray value={value} path={path} defaultExpanded={shouldBeExpanded} />
40
45
  }
@@ -1,3 +1,5 @@
1
+ import { TerminalIcon } from "lucide-react";
2
+ import { Terminal } from "../DebuggerTerminal";
1
3
  import ValueBoolean from "./ValueBoolean";
2
4
  import ValueFunction from "./ValueFunction";
3
5
  import ValueKeyword from "./ValueKeyword";
@@ -42,6 +44,16 @@ export default function ParseValueSimple({
42
44
  if (value === null)
43
45
  return <ValueKeyword value="null" />
44
46
 
47
+ if (value instanceof Terminal)
48
+ return (
49
+ <div className="inline-flex gap-1 align-bottom text-white">
50
+ <div className="size-5">
51
+ <TerminalIcon />
52
+ </div>
53
+ <span>{value.name}</span>
54
+ </div>)
55
+ ;
56
+
45
57
  const className = Object.getPrototypeOf(value) !== Object.prototype ? value.constructor.name : null;
46
58
 
47
59
  if (className === null || className.length > MAX_LENGTH)
@@ -0,0 +1,341 @@
1
+ import { createContext, useContext, useLayoutEffect, useRef, useState } from "react";
2
+ import { LogItem, LogRole, LogType, Terminal } from "../DebuggerTerminal"
3
+ import clsx from "clsx";
4
+ import { KeyboardButton, matchBool, matchKeyEvent, matchMouseEvent, MouseButton, Optional } from "@hrnec06/util";
5
+ import ParseValue from "./DebugParser";
6
+ import { ChevronRight, CircleAlert, SendIcon, TerminalIcon, Trash, TriangleAlert } from "lucide-react";
7
+ import ResizeableBox from "../../ResizeableBox/ResizeableBox";
8
+ import { Chevron_Toggle } from "../DebuggerSymbols";
9
+
10
+ interface LocalTerminalContext {
11
+ terminal: Terminal,
12
+ path: string[]
13
+ }
14
+ const LocalTerminalContext = createContext<Optional<LocalTerminalContext>>(undefined);
15
+
16
+ function useLocalTerminalContext()
17
+ {
18
+ const ctx = useContext(LocalTerminalContext);
19
+
20
+ if (!ctx)
21
+ throw new Error("useLocalTerminalContext may be used only within LocalTerminalContext.Provider!");
22
+
23
+ return ctx;
24
+ }
25
+
26
+ interface DebugTerminalProps {
27
+ terminal: Terminal,
28
+ path: string[],
29
+ defaultExpanded?: boolean
30
+ }
31
+ export default function DebugTerminal({ terminal, path, defaultExpanded }: DebugTerminalProps)
32
+ {
33
+ const [showing, setShowing] = useState(!!defaultExpanded);
34
+
35
+ const handleToggle = (value: boolean) => {
36
+ setShowing(value);
37
+ }
38
+
39
+ return (
40
+ <LocalTerminalContext.Provider
41
+ value={{
42
+ terminal: terminal,
43
+ path: path
44
+ }}
45
+ >
46
+ <span className="text-white inline-flex items-center align-bottom">
47
+ <div className="mr-1">
48
+ <TerminalIcon className="size-5" />
49
+ </div>
50
+
51
+ <span>
52
+ {terminal.name}
53
+ </span>
54
+
55
+ <Chevron_Toggle
56
+ expanded={showing}
57
+ onToggle={handleToggle}
58
+ />
59
+ </span>
60
+
61
+ <TerminalMain show={showing} />
62
+ </LocalTerminalContext.Provider>
63
+ )
64
+ }
65
+
66
+ interface TerminalMainProps {
67
+ show: boolean
68
+ }
69
+ function TerminalMain({ show }: TerminalMainProps)
70
+ {
71
+ const [terminalHeight, setTerminalHeight] = useState(300);
72
+
73
+ const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
74
+ e.stopPropagation();
75
+ }
76
+
77
+ if (!show)
78
+ return undefined;
79
+
80
+ return (
81
+ <div
82
+ className="block"
83
+ style={{
84
+ height: terminalHeight
85
+ }}
86
+ >
87
+ <ResizeableBox
88
+ $allowedSides={["bottom"]}
89
+ $onResize={(size) => {
90
+ setTerminalHeight(size[1]);
91
+ }}
92
+ $size={[0, terminalHeight]}
93
+ >
94
+ <div
95
+ className={clsx(
96
+ "flex flex-col h-full",
97
+ "border-b border-dashed border-b-zinc-700 pb-3"
98
+ )}
99
+
100
+ onMouseDown={handleMouseDown}
101
+ >
102
+ <TerminalLog />
103
+
104
+ <TerminalInput />
105
+ </div>
106
+ </ResizeableBox>
107
+ </div>
108
+ );
109
+ }
110
+
111
+ function TerminalLog()
112
+ {
113
+ const { terminal } = useLocalTerminalContext();
114
+
115
+ const divRef = useRef<HTMLDivElement>(null);
116
+
117
+ useLayoutEffect(() => {
118
+ if (!divRef.current) return;
119
+
120
+ divRef.current.scrollTop = divRef.current.scrollHeight;
121
+ }, [divRef, terminal.getLog()]);
122
+
123
+ const handleWheel = (e: React.WheelEvent<HTMLDivElement>) => {
124
+ e.stopPropagation();
125
+ }
126
+
127
+ return (
128
+ <div
129
+ className={clsx(
130
+ "grid flex-1 overflow-auto relative",
131
+ "bg-zinc-900 mt-2 p-2 rounded-md"
132
+ )}
133
+
134
+ onWheel={handleWheel}
135
+ >
136
+ {!!terminal.getLog().length && (<TerminalClearButton />)}
137
+
138
+ <div
139
+ ref={divRef}
140
+
141
+ className="flex flex-col max-h-full overflow-y-auto mt-auto relative"
142
+ >
143
+ {terminal.getLog().map((item, ix) => (
144
+ <TerminalItem
145
+ key={ix}
146
+ item={item}
147
+ index={ix}
148
+ />
149
+ ))}
150
+ </div>
151
+ </div>
152
+ );
153
+ }
154
+
155
+ interface TerminalItemProps {
156
+ item: LogItem,
157
+ index: number
158
+ }
159
+ function TerminalItem({ item, index }: TerminalItemProps)
160
+ {
161
+ const { path } = useLocalTerminalContext();
162
+
163
+ return (
164
+ <div
165
+ className={clsx(
166
+ "grid grid-cols-[18px_24px_1fr]",
167
+ matchBool({
168
+ "text-red-400": item.type === LogType.Error,
169
+ "text-yellow-400": item.type === LogType.Warning,
170
+ }) ?? "text-white"
171
+ )}
172
+ >
173
+ <div />
174
+
175
+ <div className="flex pt-0.5">
176
+ {(() => {
177
+ switch (true)
178
+ {
179
+ case item.role === LogRole.Input: return (<ChevronRight className="size-4" />);
180
+ case item.type === LogType.Error: return (<TriangleAlert className="size-4" />);
181
+ case item.type === LogType.Warning: return (<CircleAlert className="size-4" />)
182
+ }
183
+ return null;
184
+ })()}
185
+ </div>
186
+ <div>
187
+ {
188
+ typeof item.value === "string"
189
+ ? (
190
+ <span
191
+ className={clsx(
192
+
193
+ )}
194
+ >
195
+ {item.value}
196
+ </span>
197
+ )
198
+ : (
199
+ <ParseValue value={item.value} path={[...path, `${index}`]} />
200
+ )
201
+ }
202
+ </div>
203
+ </div>
204
+ );
205
+ }
206
+
207
+ function TerminalClearButton()
208
+ {
209
+ const { terminal } = useLocalTerminalContext();
210
+
211
+ const handleClick = () => {
212
+ terminal.clear();
213
+ }
214
+
215
+ return (
216
+ <button
217
+ onClick={handleClick}
218
+ className={clsx(
219
+ "absolute right-8 top-2",
220
+ "text-zinc-500 hover:text-zinc-200 cursor-pointer"
221
+ )}
222
+ >
223
+ <Trash className="size-5" />
224
+ </button>
225
+ )
226
+ }
227
+
228
+ function TerminalInput()
229
+ {
230
+ const { terminal } = useLocalTerminalContext();
231
+
232
+ const [input, setInput] = useState("");
233
+ const [historyOffset, setHistoryOffset] = useState(0);
234
+
235
+ const inputRef = useRef<HTMLInputElement>(null);
236
+
237
+ if (!terminal.hasInput())
238
+ return undefined;
239
+
240
+ const send = () => {
241
+ const value = input.trim();
242
+
243
+ if (!value)
244
+ return;
245
+
246
+ setInput("");
247
+ setHistoryOffset(0);
248
+
249
+ const result = terminal.input(value);
250
+
251
+ console.log(result);
252
+ }
253
+
254
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
255
+ setInput(e.target.value);
256
+ }
257
+
258
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
259
+ e.stopPropagation();
260
+
261
+ if (matchKeyEvent(e.nativeEvent, KeyboardButton.Enter))
262
+ {
263
+ send();
264
+ }
265
+ else if (matchKeyEvent(e.nativeEvent, KeyboardButton.ArrowUp))
266
+ {
267
+ e.preventDefault();
268
+
269
+ const history = terminal.getInputHistory();
270
+ const item = history[history.length - 1 - historyOffset];
271
+
272
+ if (item !== undefined)
273
+ {
274
+ setHistoryOffset(prev => prev + 1);
275
+ setInput(item);
276
+ }
277
+ }
278
+ else if (matchKeyEvent(e.nativeEvent, KeyboardButton.ArrowDown))
279
+ {
280
+ e.preventDefault();
281
+
282
+ const history = terminal.getInputHistory();
283
+ const item = history[history.length + 1 - historyOffset];
284
+
285
+ if (item !== undefined)
286
+ {
287
+ setHistoryOffset(prev => prev - 1);
288
+ setInput(item);
289
+ }
290
+ else
291
+ {
292
+ setInput("");
293
+ setHistoryOffset(0);
294
+ }
295
+ }
296
+ }
297
+
298
+ const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
299
+ if (!matchMouseEvent(e.nativeEvent, MouseButton.Left)) return;
300
+
301
+ e.stopPropagation();
302
+
303
+ send();
304
+ }
305
+
306
+ return (
307
+ <div
308
+ className={clsx(
309
+ "flex shrink-0 overflow-hidden",
310
+ "bg-zinc-700 rounded-md text-white mt-2",
311
+ "focus-within:ring-2 ring-sky-500 transition-all"
312
+ )}
313
+ >
314
+ <div className="flex items-center justify-center px-2">
315
+ <TerminalIcon className="size-5 text-zinc-500" />
316
+ </div>
317
+
318
+ <input
319
+ ref={inputRef}
320
+
321
+ type="text"
322
+ className={clsx(
323
+ "flex-1",
324
+ "outline-none"
325
+ )}
326
+
327
+ onChange={handleChange}
328
+ onKeyDown={handleKeyDown}
329
+
330
+ value={input}
331
+ />
332
+
333
+ <button
334
+ className="p-2 cursor-pointer hover:bg-zinc-600 transition-colors rounded-md"
335
+ onClick={handleClick}
336
+ >
337
+ <SendIcon className="size-5" />
338
+ </button>
339
+ </div>
340
+ )
341
+ }
@@ -0,0 +1,50 @@
1
+ import { KeyboardButton } from "@hrnec06/util";
2
+ import useKeyListener from "../../hooks/useKeyListener";
3
+ import useTransition from "../../hooks/useTransition";
4
+ import DialogBackdrop from "./DialogBackdrop";
5
+ import DialogCloseButton from "./DialogCloseButton";
6
+ import { DialogContext } from "./DialogContext";
7
+ import DialogPanel from "./DialogPanel";
8
+ import { useEffect } from "react";
9
+
10
+ interface DialogProps extends React.PropsWithChildren {
11
+ open?: boolean,
12
+ duration?: number,
13
+ onClose?: () => void
14
+ }
15
+ function Dialog({
16
+ open = false,
17
+ duration = 300,
18
+ onClose,
19
+ children,
20
+ }: DialogProps)
21
+ {
22
+ const pressingEsc = useKeyListener(KeyboardButton.Escape);
23
+
24
+ useEffect(() => {
25
+ if (!open || !pressingEsc) return;
26
+
27
+ onClose?.();
28
+ }, [pressingEsc]);
29
+
30
+ return (
31
+ <DialogContext.Provider
32
+ value={{
33
+ open: open,
34
+ onClose: onClose,
35
+
36
+ options: {
37
+ animationDuration: duration
38
+ },
39
+ }}
40
+ >
41
+ {children}
42
+ </DialogContext.Provider>
43
+ );
44
+ }
45
+
46
+ Dialog.Backdrop = DialogBackdrop;
47
+ Dialog.CloseButton = DialogCloseButton;
48
+ Dialog.Panel = DialogPanel;
49
+
50
+ export default Dialog;
@@ -0,0 +1,45 @@
1
+ import clsx from "clsx";
2
+ import useDialog from "./DialogContext";
3
+ import useTransition, { transitionDataAttributes } from "../../hooks/useTransition";
4
+ import { useRef } from "react";
5
+
6
+ interface DialogBackdropProps extends Omit<React.HTMLProps<HTMLDivElement>, 'children'>
7
+ {
8
+ $transition?: boolean
9
+ }
10
+ export default function DialogBackdrop({
11
+ $transition = false,
12
+
13
+ ...props
14
+ }: DialogBackdropProps)
15
+ {
16
+ const dialog = useDialog();
17
+
18
+ const divRef = useRef<HTMLDivElement>(null);
19
+
20
+ const [visible, transitionData] = useTransition($transition, divRef.current, dialog.open);
21
+
22
+ if (!visible)
23
+ return undefined;
24
+
25
+ const handleClick = () => {
26
+ console.log("close");
27
+
28
+ dialog.onClose?.();
29
+ }
30
+
31
+ return (
32
+ <div
33
+ {...props}
34
+ {...transitionDataAttributes(transitionData)}
35
+
36
+ ref={divRef}
37
+
38
+ onClick={handleClick}
39
+
40
+ className={clsx(
41
+ props.className,
42
+ )}
43
+ />
44
+ )
45
+ }
@@ -0,0 +1,25 @@
1
+ import useDialog from "./DialogContext";
2
+
3
+ interface DialogCloseButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
4
+
5
+ }
6
+ export default function DialogCloseButton({
7
+ ...props
8
+ }: DialogCloseButtonProps)
9
+ {
10
+ const dialog = useDialog();
11
+
12
+ const handleClick = () => {
13
+ dialog.onClose?.();
14
+ }
15
+
16
+ return (
17
+ <button
18
+ {...props}
19
+
20
+ onClick={handleClick}
21
+ >
22
+ {props.children}
23
+ </button>
24
+ )
25
+ }
@@ -0,0 +1,21 @@
1
+ import { Optional } from "@hrnec06/util";
2
+ import { createContext, useContext } from "react";
3
+
4
+ interface DialogContext {
5
+ open: boolean,
6
+ options: {
7
+ animationDuration: number,
8
+ },
9
+ onClose?: () => void
10
+ }
11
+ export const DialogContext = createContext<Optional<DialogContext>>(undefined);
12
+
13
+ export default function useDialog()
14
+ {
15
+ const ctx = useContext(DialogContext);
16
+
17
+ if (!ctx)
18
+ throw new Error("useDialog may be used only within DialogContext.Provider!");
19
+
20
+ return ctx;
21
+ }
@@ -0,0 +1,35 @@
1
+ import clsx from "clsx";
2
+ import useDialog from "./DialogContext";
3
+ import useTransition, { transitionDataAttributes } from "../../hooks/useTransition";
4
+ import { useRef } from "react";
5
+
6
+ interface DialogPanelProps extends React.HTMLProps<HTMLDivElement> {
7
+ $transition?: boolean
8
+ }
9
+ export default function DialogPanel({
10
+ $transition = false,
11
+ ...props
12
+ }: DialogPanelProps)
13
+ {
14
+ const dialog = useDialog();
15
+
16
+ const divRef = useRef<HTMLDivElement>(null);
17
+
18
+ const [visible, transitionData] = useTransition($transition, divRef.current, dialog.open);
19
+
20
+ if (!visible)
21
+ return undefined;
22
+
23
+ return (
24
+ <div
25
+ {...props}
26
+ {...transitionDataAttributes(transitionData)}
27
+
28
+ ref={divRef}
29
+
30
+ className={props.className}
31
+ >
32
+ {props.children}
33
+ </div>
34
+ )
35
+ }
@@ -0,0 +1,13 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { disposables } from '@hrnec06/util';
3
+
4
+ /**
5
+ * @credit https://github.com/tailwindlabs/headlessui/blob/main/packages/%40headlessui-react/src/hooks/use-disposables.ts
6
+ */
7
+ export default function useDisposables() {
8
+ const [_disposables] = useState(disposables);
9
+
10
+ useEffect(() => () => _disposables.dispose(), [_disposables]);
11
+
12
+ return _disposables;
13
+ }
@@ -0,0 +1,32 @@
1
+ import { Flags, ReactUtils } from "@hrnec06/util";
2
+ import { useCallback, useMemo, useState } from "react";
3
+
4
+ export class FlagsManager extends Flags
5
+ {
6
+ constructor(
7
+ private setState: ReactUtils.SetState<number>,
8
+ initialFlags?: number,
9
+ )
10
+ {
11
+ super(initialFlags);
12
+ }
13
+
14
+ public setFlags(flags: number): Flags {
15
+ this.flags = flags;
16
+
17
+ this.setState(flags);
18
+
19
+ return this;
20
+ }
21
+ }
22
+
23
+ function useFlags(initialFlags: number = 0): FlagsManager
24
+ {
25
+ const [_, setFlags] = useState(initialFlags);
26
+
27
+ const manager = useMemo(() => new FlagsManager(setFlags), []);
28
+
29
+ return manager;
30
+ }
31
+
32
+ export default useFlags;
@@ -0,0 +1,19 @@
1
+ import { useState } from "react";
2
+
3
+ const namespaces: Record<string, number> = {};
4
+
5
+ function getId(namespace: string): string
6
+ {
7
+ const nextID = (namespaces[namespace] ?? 0) + 1;
8
+
9
+ namespaces[namespace] = nextID;
10
+
11
+ return `nid_${namespace}_${nextID}`;
12
+ }
13
+
14
+ export default function useNamespacedId(namespace: string): string
15
+ {
16
+ const [id] = useState(() => getId(namespace));
17
+
18
+ return id;
19
+ }
@@ -0,0 +1,261 @@
1
+ import { disposables, mapRecord, Nullable } from "@hrnec06/util";
2
+ import { useLayoutEffect, useRef, useState } from "react";
3
+ import useFlags from "./useFlags";
4
+ import useDisposables from "./useDisposables";
5
+
6
+ enum TransitionState {
7
+ None = 0,
8
+
9
+ Closed = 1 << 0,
10
+
11
+ Enter = 1 << 1,
12
+ Leave = 1 << 2,
13
+ }
14
+
15
+ export interface TransitionStates {
16
+ enter?: boolean,
17
+ leave?: boolean,
18
+ closed?: boolean,
19
+ transition?: boolean
20
+ }
21
+
22
+ export function transitionDataAttributes(data: TransitionStates)
23
+ {
24
+ return mapRecord(data, (key, value) => [
25
+ `data-${key}`,
26
+ value === true ? "true" : undefined
27
+ ]);
28
+ }
29
+
30
+ export default function useTransition(
31
+ enabled: boolean,
32
+ element: Nullable<HTMLElement>,
33
+ show: boolean
34
+ ): [visible: boolean, data: TransitionStates]
35
+ {
36
+ const [visible, setVisible] = useState(show);
37
+
38
+ const flags = useFlags(
39
+ (enabled && visible) ? TransitionState.Enter | TransitionState.Closed : TransitionState.None
40
+ );
41
+
42
+ const inFlight = useRef(false);
43
+ const cancelledRef = useRef(false);
44
+
45
+ const d = useDisposables();
46
+
47
+ useLayoutEffect(() => {
48
+ if (!enabled) return;
49
+
50
+ if (show)
51
+ {
52
+ setVisible(true);
53
+ }
54
+
55
+ if (!element)
56
+ {
57
+ if (show)
58
+ {
59
+ flags.addFlag(TransitionState.Enter | TransitionState.Closed);
60
+ }
61
+
62
+ return;
63
+ }
64
+
65
+ return transition(
66
+ element,
67
+ {
68
+ inFlight: inFlight,
69
+ prepare: () => {
70
+ if (cancelledRef.current)
71
+ {
72
+ cancelledRef.current = false;
73
+ }
74
+ else
75
+ {
76
+ cancelledRef.current = inFlight.current;
77
+ }
78
+
79
+ inFlight.current = true;
80
+
81
+ if (cancelledRef.current) return;
82
+
83
+ if (show)
84
+ {
85
+ flags.addFlag(TransitionState.Enter | TransitionState.Closed);
86
+ flags.removeFlag(TransitionState.Leave);
87
+ }
88
+ else
89
+ {
90
+ flags.addFlag(TransitionState.Leave);
91
+ flags.removeFlag(TransitionState.Enter);
92
+ }
93
+ },
94
+ run: () => {
95
+ if (cancelledRef.current)
96
+ {
97
+ if (show)
98
+ {
99
+ flags.addFlag(TransitionState.Leave);
100
+ flags.removeFlag(TransitionState.Enter | TransitionState.Closed);
101
+ }
102
+ else
103
+ {
104
+ flags.addFlag(TransitionState.Enter | TransitionState.Closed);
105
+ flags.removeFlag(TransitionState.Leave);
106
+ }
107
+ }
108
+ else
109
+ {
110
+ if (show)
111
+ {
112
+ flags.removeFlag(TransitionState.Closed);
113
+ }
114
+ else
115
+ {
116
+ flags.addFlag(TransitionState.Closed);
117
+ }
118
+ }
119
+ },
120
+ done: () => {
121
+ if (cancelledRef.current)
122
+ {
123
+ if (hasPendingTransitions(element))
124
+ {
125
+ return;
126
+ }
127
+ }
128
+
129
+ inFlight.current = false;
130
+
131
+ flags.removeFlag(TransitionState.Enter | TransitionState.Leave | TransitionState.Closed);
132
+
133
+ if (!show)
134
+ {
135
+ setVisible(false);
136
+ }
137
+ }
138
+ }
139
+ );
140
+ }, [enabled, show, element, d]);
141
+
142
+ if (!enabled)
143
+ {
144
+ return [
145
+ show,
146
+ {
147
+ closed: undefined,
148
+ enter: undefined,
149
+ leave: undefined,
150
+ transition: undefined
151
+ }
152
+ ] as const;
153
+ }
154
+
155
+ return [
156
+ visible,
157
+ {
158
+ closed: flags.hasFlag(TransitionState.Closed),
159
+ enter: flags.hasFlag(TransitionState.Enter),
160
+ leave: flags.hasFlag(TransitionState.Leave),
161
+ transition: flags.hasFlag(TransitionState.Enter) || flags.hasFlag(TransitionState.Leave)
162
+ }
163
+ ] as const;
164
+ }
165
+
166
+ function transition(
167
+ node: HTMLElement,
168
+ {
169
+ prepare,
170
+ run,
171
+ done,
172
+ inFlight
173
+ }: {
174
+ prepare: () => void,
175
+ run: () => void,
176
+ done: () => void,
177
+ inFlight: React.RefObject<boolean>
178
+ }
179
+ )
180
+ {
181
+ const d = disposables();
182
+
183
+ prepareTransition(node, {
184
+ prepare,
185
+ inFlight
186
+ });
187
+
188
+ d.nextFrame(() => {
189
+ run();
190
+
191
+ d.requestAnimationFrame(() => {
192
+ d.add(waitForTransition(node, done));
193
+ });
194
+ });
195
+
196
+ return d.dispose;
197
+ }
198
+
199
+ function waitForTransition(node: Nullable<HTMLElement>, done: () => void)
200
+ {
201
+ const d = disposables();
202
+
203
+ if (!node) return d.dispose;
204
+
205
+ let cancelled = false;
206
+ d.add(() => {
207
+ cancelled = true;
208
+ });
209
+
210
+ const transitions = node.getAnimations?.().filter((animation) => animation instanceof CSSTransition) ?? [];
211
+
212
+ if (transitions.length === 0)
213
+ {
214
+ done();
215
+
216
+ return d.dispose;
217
+ }
218
+
219
+ Promise.allSettled(transitions.map((transition) => transition.finished))
220
+ .then(() => {
221
+ if (!cancelled)
222
+ {
223
+ done();
224
+ }
225
+ });
226
+
227
+ return d.dispose;
228
+ }
229
+
230
+ function prepareTransition(
231
+ node: HTMLElement,
232
+ { inFlight, prepare }: { inFlight?: React.RefObject<boolean>, prepare: () => void }
233
+ )
234
+ {
235
+ if (inFlight?.current)
236
+ {
237
+ prepare();
238
+ return;
239
+ }
240
+
241
+ const previous = node.style.transition;
242
+
243
+ node.style.transition = "none";
244
+
245
+ prepare();
246
+
247
+ // Trigger a reflow, flushing the CSS changes
248
+ node.offsetHeight;
249
+
250
+ // Reset the transition to what it was before
251
+ node.style.transition = previous;
252
+ }
253
+
254
+ function hasPendingTransitions(node: HTMLElement): boolean
255
+ {
256
+ const animations = node.getAnimations?.() ?? []
257
+
258
+ return animations.some((animation) => {
259
+ return animation instanceof CSSTransition && animation.playState !== 'finished'
260
+ })
261
+ }
package/src/index.ts CHANGED
@@ -6,17 +6,20 @@ import useWindowSize from "./hooks/useWindowSize";
6
6
  import useEfficientRef from "./hooks/useEfficientRef";
7
7
  import useEfficientState from "./hooks/useEfficientState";
8
8
  import useUUID from "./hooks/useUUID";
9
+ import useFlags from "./hooks/useFlags";
9
10
 
10
11
  import useSignal, { Signal } from "./hooks/useSignal";
11
-
12
12
  import useLazySignal, { LazySignal } from "./hooks/useLazySignal";
13
13
 
14
- import Debugger from './components/Debugger/Debugger';
14
+ import * as Debugger from './components/Debugger/Debugger';
15
+
15
16
  import ResizeableBox from "./components/ResizeableBox/ResizeableBox";
17
+ import Dialog from "./components/Dialog/Dialog";
16
18
 
17
19
  import * as util from './lib/utils';
18
20
 
19
21
  export {
22
+ // Hooks
20
23
  useKeyListener,
21
24
  useListener,
22
25
  useUpdatedRef,
@@ -25,15 +28,19 @@ export {
25
28
  useEfficientRef,
26
29
  useEfficientState,
27
30
  useUUID,
31
+ useFlags,
28
32
 
33
+ // Signals
29
34
  useSignal,
30
35
  Signal,
31
-
32
36
  useLazySignal,
33
37
  LazySignal,
34
38
 
39
+ // Components
35
40
  Debugger,
36
41
  ResizeableBox,
42
+ Dialog,
37
43
 
38
- util
44
+ // Utilities
45
+ util,
39
46
  };