@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.
- package/dist/index.js +6598 -6372
- package/dist/styles.css +1 -1
- package/dist/ui/components/nm-field/nm-field.d.ts +2 -0
- package/dist/ui/components/nm-textarea/nm-textarea.d.ts +12 -2
- package/package.json +1 -1
- package/registry/index.json +179 -3
- package/registry/ui/components/nm-field/nm-field.tsx +6 -1
- package/registry/ui/components/nm-textarea/nm-textarea.css +200 -1
- package/registry/ui/components/nm-textarea/nm-textarea.tsx +417 -3
|
@@ -1,6 +1,420 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
5
|
-
|
|
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
|
}
|