@nordmanai/nif 0.2.3 → 0.2.5

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.
@@ -1,6 +1,388 @@
1
- import type { ComponentProps } from 'react';
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useRef,
6
+ useState,
7
+ type ChangeEvent,
8
+ type ComponentProps,
9
+ type ReactNode,
10
+ } from 'react';
11
+ import { createPortal } from 'react-dom';
2
12
  import { nmClass } from '../../lib/nm-class.ts';
13
+ import { NmIcon } from '../nm-icon/nm-icon.tsx';
14
+ import { NmProse } from '../nm-prose/nm-prose.tsx';
15
+ import { NmSidePanel, NmSidePanelBody } from '../nm-side-panel/nm-side-panel.tsx';
16
+ import { NmToolbar, NmToolbarButton, NmToolbarSpacer } from '../nm-toolbar/nm-toolbar.tsx';
17
+ import { createLogger } from '../../kit-logger.ts';
3
18
 
4
- export function NmTextarea({ className, ...props }: ComponentProps<'textarea'>) {
5
- return <textarea data-slot="textarea" className={nmClass('nm-textarea', className)} {...props} />;
19
+ const logger = createLogger('interface-nm-textarea');
20
+
21
+ export type NmTextareaVariant = 'default' | 'panel';
22
+
23
+ export type NmTextareaProps = ComponentProps<'textarea'> & {
24
+ variant?: NmTextareaVariant;
25
+ openInPanel?: boolean;
26
+ panelTitle?: string;
27
+ panelButtonLabel?: string;
28
+ panelDefaultTab?: 'code' | 'view';
29
+ renderMarkdown?: (markdown: string) => ReactNode;
30
+ };
31
+
32
+ function renderInline(text: string): ReactNode[] {
33
+ const parts: ReactNode[] = [];
34
+ const regex = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\[[^\]]+\]\([^)]+\))/g;
35
+ let lastIndex = 0;
36
+ let match: RegExpExecArray | null;
37
+
38
+ while ((match = regex.exec(text)) !== null) {
39
+ if (match.index > lastIndex) {
40
+ parts.push(text.slice(lastIndex, match.index));
41
+ }
42
+ const token = match[0];
43
+ if (token.startsWith('**') && token.endsWith('**')) {
44
+ parts.push(<strong key={match.index}>{token.slice(2, -2)}</strong>);
45
+ } else if (token.startsWith('*') && token.endsWith('*')) {
46
+ parts.push(<em key={match.index}>{token.slice(1, -1)}</em>);
47
+ } else if (token.startsWith('`') && token.endsWith('`')) {
48
+ parts.push(<code key={match.index}>{token.slice(1, -1)}</code>);
49
+ } else if (token.startsWith('[') && token.includes('](')) {
50
+ const closeBracket = token.indexOf('](');
51
+ const label = token.slice(1, closeBracket);
52
+ const url = token.slice(closeBracket + 2, -1);
53
+ parts.push(
54
+ <a key={match.index} href={url} target="_blank" rel="noreferrer">
55
+ {label}
56
+ </a>,
57
+ );
58
+ }
59
+ lastIndex = regex.lastIndex;
60
+ }
61
+ if (lastIndex < text.length) {
62
+ parts.push(text.slice(lastIndex));
63
+ }
64
+ return parts.length > 0 ? parts : [text];
65
+ }
66
+
67
+ function parseMarkdown(md: string): ReactNode {
68
+ if (!md.trim()) {
69
+ return <p className="nm-textarea-panel__empty">Nothing to preview</p>;
70
+ }
71
+
72
+ const lines = md.split('\n');
73
+ const nodes: ReactNode[] = [];
74
+ let i = 0;
75
+
76
+ while (i < lines.length) {
77
+ const line = lines[i];
78
+
79
+ if (line.startsWith('```')) {
80
+ const codeLines: string[] = [];
81
+ i++;
82
+ while (i < lines.length && !lines[i].startsWith('```')) {
83
+ codeLines.push(lines[i]);
84
+ i++;
85
+ }
86
+ i++;
87
+ nodes.push(
88
+ <pre key={`code-${i}`}>
89
+ <code>{codeLines.join('\n')}</code>
90
+ </pre>,
91
+ );
92
+ continue;
93
+ }
94
+
95
+ if (line.startsWith('#### ')) {
96
+ nodes.push(<h4 key={`h4-${i}`}>{renderInline(line.slice(5))}</h4>);
97
+ i++;
98
+ continue;
99
+ }
100
+ if (line.startsWith('### ')) {
101
+ nodes.push(<h3 key={`h3-${i}`}>{renderInline(line.slice(4))}</h3>);
102
+ i++;
103
+ continue;
104
+ }
105
+ if (line.startsWith('## ')) {
106
+ nodes.push(<h2 key={`h2-${i}`}>{renderInline(line.slice(3))}</h2>);
107
+ i++;
108
+ continue;
109
+ }
110
+ if (line.startsWith('# ')) {
111
+ nodes.push(<h1 key={`h1-${i}`}>{renderInline(line.slice(2))}</h1>);
112
+ i++;
113
+ continue;
114
+ }
115
+
116
+ if (/^(\*\*\*|---|___)$/.test(line.trim())) {
117
+ nodes.push(<hr key={`hr-${i}`} />);
118
+ i++;
119
+ continue;
120
+ }
121
+
122
+ if (line.startsWith('> ')) {
123
+ const quoteLines: string[] = [line.slice(2)];
124
+ i++;
125
+ while (i < lines.length && lines[i].startsWith('> ')) {
126
+ quoteLines.push(lines[i].slice(2));
127
+ i++;
128
+ }
129
+ nodes.push(
130
+ <blockquote key={`quote-${i}`}>
131
+ {quoteLines.map((ql, idx) => (
132
+ <p key={idx}>{renderInline(ql)}</p>
133
+ ))}
134
+ </blockquote>,
135
+ );
136
+ continue;
137
+ }
138
+
139
+ if (/^[-*]\s+/.test(line)) {
140
+ const items: string[] = [line.replace(/^[-*]\s+/, '')];
141
+ i++;
142
+ while (i < lines.length && /^[-*]\s+/.test(lines[i])) {
143
+ items.push(lines[i].replace(/^[-*]\s+/, ''));
144
+ i++;
145
+ }
146
+ nodes.push(
147
+ <ul key={`ul-${i}`}>
148
+ {items.map((item, idx) => (
149
+ <li key={idx}>{renderInline(item)}</li>
150
+ ))}
151
+ </ul>,
152
+ );
153
+ continue;
154
+ }
155
+
156
+ if (/^\d+\.\s+/.test(line)) {
157
+ const items: string[] = [line.replace(/^\d+\.\s+/, '')];
158
+ i++;
159
+ while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
160
+ items.push(lines[i].replace(/^\d+\.\s+/, ''));
161
+ i++;
162
+ }
163
+ nodes.push(
164
+ <ol key={`ol-${i}`}>
165
+ {items.map((item, idx) => (
166
+ <li key={idx}>{renderInline(item)}</li>
167
+ ))}
168
+ </ol>,
169
+ );
170
+ continue;
171
+ }
172
+
173
+ if (!line.trim()) {
174
+ i++;
175
+ continue;
176
+ }
177
+
178
+ const paraLines: string[] = [line];
179
+ i++;
180
+ while (
181
+ i < lines.length &&
182
+ lines[i].trim() &&
183
+ !lines[i].startsWith('#') &&
184
+ !lines[i].startsWith('```') &&
185
+ !lines[i].startsWith('> ') &&
186
+ !/^[-*]\s+/.test(lines[i]) &&
187
+ !/^\d+\.\s+/.test(lines[i]) &&
188
+ !/^(\*\*\*|---|___)$/.test(lines[i].trim())
189
+ ) {
190
+ paraLines.push(lines[i]);
191
+ i++;
192
+ }
193
+ nodes.push(<p key={`p-${i}`}>{renderInline(paraLines.join(' '))}</p>);
194
+ }
195
+
196
+ return nodes;
197
+ }
198
+
199
+ export const NmTextarea = forwardRef<HTMLTextAreaElement, NmTextareaProps>(
200
+ function NmTextarea(
201
+ {
202
+ className,
203
+ variant = 'default',
204
+ openInPanel = false,
205
+ panelTitle,
206
+ panelButtonLabel = 'Open in panel',
207
+ panelDefaultTab = 'code',
208
+ renderMarkdown,
209
+ value,
210
+ defaultValue,
211
+ onChange,
212
+ ...props
213
+ },
214
+ forwardedRef,
215
+ ) {
216
+ const isPanelEnabled = variant === 'panel' || openInPanel;
217
+ const [panelOpen, setPanelOpen] = useState(false);
218
+ const [view, setView] = useState<'code' | 'view'>(panelDefaultTab);
219
+ const [copied, setCopied] = useState(false);
220
+ const triggerRef = useRef<HTMLButtonElement>(null);
221
+ const internalTextareaRef = useRef<HTMLTextAreaElement | null>(null);
222
+ const [uncontrolledValue, setUncontrolledValue] = useState(() => String(defaultValue ?? ''));
223
+
224
+ const isControlled = value !== undefined;
225
+ const currentValue = isControlled ? String(value) : uncontrolledValue;
226
+
227
+ const handleTextareaChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
228
+ if (!isControlled) {
229
+ setUncontrolledValue(event.target.value);
230
+ }
231
+ onChange?.(event);
232
+ };
233
+
234
+ const handlePanelTextChange = (nextText: string) => {
235
+ if (!isControlled) {
236
+ setUncontrolledValue(nextText);
237
+ }
238
+ if (internalTextareaRef.current) {
239
+ internalTextareaRef.current.value = nextText;
240
+ }
241
+ if (onChange) {
242
+ const syntheticEvent = {
243
+ target: { value: nextText, name: props.name },
244
+ currentTarget: { value: nextText, name: props.name },
245
+ } as ChangeEvent<HTMLTextAreaElement>;
246
+ onChange(syntheticEvent);
247
+ }
248
+ };
249
+
250
+ const handleCopy = () => {
251
+ if (typeof navigator !== 'undefined' && navigator.clipboard) {
252
+ void navigator.clipboard.writeText(currentValue);
253
+ setCopied(true);
254
+ setTimeout(() => setCopied(false), 1500);
255
+ }
256
+ };
257
+
258
+ const handleClose = useCallback(() => {
259
+ setPanelOpen(false);
260
+ triggerRef.current?.focus();
261
+ }, []);
262
+
263
+ useEffect(() => {
264
+ if (!panelOpen) return;
265
+ const onKeyDown = (e: KeyboardEvent) => {
266
+ if (e.key === 'Escape') {
267
+ e.preventDefault();
268
+ handleClose();
269
+ }
270
+ };
271
+ window.addEventListener('keydown', onKeyDown);
272
+ return () => window.removeEventListener('keydown', onKeyDown);
273
+ }, [panelOpen, handleClose]);
274
+
275
+ const textareaElement = (
276
+ <textarea
277
+ className={nmClass('nm-textarea', className)}
278
+ ref={(node) => {
279
+ internalTextareaRef.current = node;
280
+ if (typeof forwardedRef === 'function') {
281
+ forwardedRef(node);
282
+ } else if (forwardedRef) {
283
+ forwardedRef.current = node;
284
+ }
285
+ }}
286
+ data-slot="textarea"
287
+ value={value}
288
+ defaultValue={defaultValue}
289
+ onChange={handleTextareaChange}
290
+ {...props}
291
+ />
292
+ );
293
+
294
+ if (!isPanelEnabled) {
295
+ return textareaElement;
296
+ }
297
+
298
+ const panelContent = panelOpen && typeof document !== 'undefined' ? (
299
+ createPortal(
300
+ <>
301
+ <div
302
+ className="nm-textarea-panel__backdrop"
303
+ onClick={handleClose}
304
+ aria-hidden="true"
305
+ />
306
+ <NmSidePanel
307
+ label={panelTitle ?? 'Markdown'}
308
+ open={panelOpen}
309
+ className="nm-textarea-side-panel"
310
+ >
311
+ <NmToolbar aria-label={panelTitle ?? 'Markdown'}>
312
+ {panelTitle ? <span className="nm-textarea-panel__title">{panelTitle}</span> : null}
313
+ <NmToolbarButton
314
+ icon="code"
315
+ label="Code"
316
+ pressed={view === 'code'}
317
+ tone={view === 'code' ? 'panel' : 'quiet'}
318
+ onClick={() => setView('code')}
319
+ />
320
+ <NmToolbarButton
321
+ icon="presentation"
322
+ label="View"
323
+ pressed={view === 'view'}
324
+ tone={view === 'view' ? 'panel' : 'quiet'}
325
+ onClick={() => setView('view')}
326
+ />
327
+ <NmToolbarSpacer />
328
+ <NmToolbarButton
329
+ icon={copied ? 'check' : 'copy'}
330
+ label={copied ? 'Copied' : 'Copy'}
331
+ tone="quiet"
332
+ onClick={handleCopy}
333
+ />
334
+ <NmToolbarButton
335
+ icon="x"
336
+ label="Close"
337
+ tone="quiet"
338
+ onClick={handleClose}
339
+ />
340
+ </NmToolbar>
341
+
342
+ <NmSidePanelBody className="nm-textarea-panel__body">
343
+ {view === 'code' ? (
344
+ <textarea
345
+ className="nm-textarea-panel__editor"
346
+ value={currentValue}
347
+ placeholder={props.placeholder ?? 'Enter markdown content...'}
348
+ aria-label="Markdown source"
349
+ autoFocus
350
+ onChange={(e) => handlePanelTextChange(e.target.value)}
351
+ />
352
+ ) : (
353
+ <NmProse className="nm-textarea-panel__preview">
354
+ {renderMarkdown ? renderMarkdown(currentValue) : parseMarkdown(currentValue)}
355
+ </NmProse>
356
+ )}
357
+ </NmSidePanelBody>
358
+ </NmSidePanel>
359
+ </>,
360
+ document.body,
361
+ )
362
+ ) : null;
363
+
364
+ logger.debug('render', variant, { panelOpen });
365
+
366
+ return (
367
+ <div className="nm-textarea-wrap">
368
+ {textareaElement}
369
+ <button
370
+ ref={triggerRef}
371
+ type="button"
372
+ className="nm-textarea__panel-trigger"
373
+ aria-label={panelButtonLabel}
374
+ aria-expanded={panelOpen}
375
+ title={panelButtonLabel}
376
+ onClick={() => setPanelOpen(true)}
377
+ >
378
+ <NmIcon id="panel" />
379
+ </button>
380
+ {panelContent}
381
+ </div>
382
+ );
383
+ },
384
+ );
385
+
386
+ export function NmTextareaPanel(props: NmTextareaProps) {
387
+ return <NmTextarea variant="panel" {...props} />;
6
388
  }