@nordmanai/nif 0.2.3 → 0.2.4

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,420 @@
1
- import type { ComponentProps } from 'react';
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useId,
6
+ useRef,
7
+ useState,
8
+ type ChangeEvent,
9
+ type ComponentProps,
10
+ type ReactNode,
11
+ } from 'react';
12
+ import { createPortal } from 'react-dom';
2
13
  import { nmClass } from '../../lib/nm-class.ts';
14
+ import { NmIcon } from '../nm-icon/nm-icon.tsx';
15
+ import { NmProse } from '../nm-prose/nm-prose.tsx';
16
+ import { NmSegmented } from '../nm-segmented/nm-segmented.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' | 'preview';
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
+ const PANEL_TABS = [
200
+ { id: 'code', label: 'Code', icon: 'code' as const },
201
+ { id: 'preview', label: 'Preview', icon: 'presentation' as const },
202
+ ] as const;
203
+
204
+ export const NmTextarea = forwardRef<HTMLTextAreaElement, NmTextareaProps>(
205
+ function NmTextarea(
206
+ {
207
+ className,
208
+ variant = 'default',
209
+ openInPanel = false,
210
+ panelTitle,
211
+ panelButtonLabel = 'Open in panel',
212
+ panelDefaultTab = 'code',
213
+ renderMarkdown,
214
+ value,
215
+ defaultValue,
216
+ onChange,
217
+ ...props
218
+ },
219
+ forwardedRef,
220
+ ) {
221
+ const isPanelEnabled = variant === 'panel' || openInPanel;
222
+ const [panelOpen, setPanelOpen] = useState(false);
223
+ const [tab, setTab] = useState<'code' | 'preview'>(panelDefaultTab);
224
+ const [copied, setCopied] = useState(false);
225
+ const triggerRef = useRef<HTMLButtonElement>(null);
226
+ const internalTextareaRef = useRef<HTMLTextAreaElement | null>(null);
227
+ const [uncontrolledValue, setUncontrolledValue] = useState(() => String(defaultValue ?? ''));
228
+
229
+ const isControlled = value !== undefined;
230
+ const currentValue = isControlled ? String(value) : uncontrolledValue;
231
+
232
+ const handleTextareaChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
233
+ if (!isControlled) {
234
+ setUncontrolledValue(event.target.value);
235
+ }
236
+ onChange?.(event);
237
+ };
238
+
239
+ const handlePanelTextChange = (nextText: string) => {
240
+ if (!isControlled) {
241
+ setUncontrolledValue(nextText);
242
+ }
243
+ if (internalTextareaRef.current) {
244
+ internalTextareaRef.current.value = nextText;
245
+ }
246
+ if (onChange) {
247
+ const syntheticEvent = {
248
+ target: { value: nextText, name: props.name },
249
+ currentTarget: { value: nextText, name: props.name },
250
+ } as ChangeEvent<HTMLTextAreaElement>;
251
+ onChange(syntheticEvent);
252
+ }
253
+ };
254
+
255
+ const handleCopy = () => {
256
+ if (typeof navigator !== 'undefined' && navigator.clipboard) {
257
+ void navigator.clipboard.writeText(currentValue);
258
+ setCopied(true);
259
+ setTimeout(() => setCopied(false), 1500);
260
+ }
261
+ };
262
+
263
+ const handleClose = useCallback(() => {
264
+ setPanelOpen(false);
265
+ triggerRef.current?.focus();
266
+ }, []);
267
+
268
+ useEffect(() => {
269
+ if (!panelOpen) return;
270
+ const onKeyDown = (e: KeyboardEvent) => {
271
+ if (e.key === 'Escape') {
272
+ e.preventDefault();
273
+ handleClose();
274
+ }
275
+ };
276
+ window.addEventListener('keydown', onKeyDown);
277
+ return () => window.removeEventListener('keydown', onKeyDown);
278
+ }, [panelOpen, handleClose]);
279
+
280
+ const titleId = useId();
281
+ const lineCount = currentValue ? currentValue.split('\n').length : 0;
282
+ const charCount = currentValue.length;
283
+
284
+ const textareaElement = (
285
+ <textarea
286
+ className={nmClass('nm-textarea', className)}
287
+ ref={(node) => {
288
+ internalTextareaRef.current = node;
289
+ if (typeof forwardedRef === 'function') {
290
+ forwardedRef(node);
291
+ } else if (forwardedRef) {
292
+ forwardedRef.current = node;
293
+ }
294
+ }}
295
+ data-slot="textarea"
296
+ value={value}
297
+ defaultValue={defaultValue}
298
+ onChange={handleTextareaChange}
299
+ {...props}
300
+ />
301
+ );
302
+
303
+ if (!isPanelEnabled) {
304
+ return textareaElement;
305
+ }
306
+
307
+ const panelContent = panelOpen && typeof document !== 'undefined' ? (
308
+ createPortal(
309
+ <>
310
+ <div
311
+ className="nm-textarea-panel__backdrop"
312
+ onClick={handleClose}
313
+ aria-hidden="true"
314
+ />
315
+ <aside
316
+ role="dialog"
317
+ aria-modal="true"
318
+ aria-labelledby={panelTitle ? titleId : undefined}
319
+ aria-label={panelTitle ? undefined : 'Markdown Editor'}
320
+ className="nm-textarea-panel"
321
+ >
322
+ <header className="nm-textarea-panel__header">
323
+ <div className="nm-textarea-panel__title-group">
324
+ {panelTitle ? (
325
+ <h3 id={titleId} className="nm-textarea-panel__title">
326
+ {panelTitle}
327
+ </h3>
328
+ ) : null}
329
+ <NmSegmented
330
+ label="Editor view"
331
+ tone="pills"
332
+ items={PANEL_TABS}
333
+ active={tab}
334
+ onSelect={(next) => setTab(next as 'code' | 'preview')}
335
+ />
336
+ </div>
337
+ <div className="nm-textarea-panel__actions">
338
+ <button
339
+ type="button"
340
+ className="nm-textarea-panel__action-btn"
341
+ aria-label={copied ? 'Copied' : 'Copy markdown'}
342
+ title={copied ? 'Copied' : 'Copy markdown'}
343
+ onClick={handleCopy}
344
+ >
345
+ <NmIcon id={copied ? 'check' : 'copy'} />
346
+ </button>
347
+ <button
348
+ type="button"
349
+ className="nm-textarea-panel__action-btn"
350
+ aria-label="Close panel"
351
+ title="Close panel"
352
+ onClick={handleClose}
353
+ >
354
+ <NmIcon id="x" />
355
+ </button>
356
+ </div>
357
+ </header>
358
+
359
+ <div className="nm-textarea-panel__body">
360
+ {tab === 'code' ? (
361
+ <textarea
362
+ className="nm-textarea-panel__editor"
363
+ value={currentValue}
364
+ placeholder={props.placeholder ?? 'Enter markdown content...'}
365
+ aria-label="Markdown source"
366
+ autoFocus
367
+ onChange={(e) => handlePanelTextChange(e.target.value)}
368
+ />
369
+ ) : (
370
+ <div className="nm-textarea-panel__preview">
371
+ <NmProse>
372
+ {renderMarkdown ? renderMarkdown(currentValue) : parseMarkdown(currentValue)}
373
+ </NmProse>
374
+ </div>
375
+ )}
376
+ </div>
377
+
378
+ <footer className="nm-textarea-panel__footer">
379
+ <span>
380
+ {lineCount} {lineCount === 1 ? 'line' : 'lines'} · {charCount} characters
381
+ </span>
382
+ <button
383
+ type="button"
384
+ className="nm-textarea-panel__done"
385
+ onClick={handleClose}
386
+ >
387
+ Done
388
+ </button>
389
+ </footer>
390
+ </aside>
391
+ </>,
392
+ document.body,
393
+ )
394
+ ) : null;
395
+
396
+ logger.debug('render', variant, { panelOpen });
397
+
398
+ return (
399
+ <div className="nm-textarea-wrap">
400
+ {textareaElement}
401
+ <button
402
+ ref={triggerRef}
403
+ type="button"
404
+ className="nm-textarea__panel-trigger"
405
+ aria-label={panelButtonLabel}
406
+ aria-expanded={panelOpen}
407
+ title={panelButtonLabel}
408
+ onClick={() => setPanelOpen(true)}
409
+ >
410
+ <NmIcon id="panel" />
411
+ </button>
412
+ {panelContent}
413
+ </div>
414
+ );
415
+ },
416
+ );
417
+
418
+ export function NmTextareaPanel(props: NmTextareaProps) {
419
+ return <NmTextarea variant="panel" {...props} />;
6
420
  }