@salemcobalt/ui 0.2.0-rc.2 → 0.2.0-rc.21

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/editor.js ADDED
@@ -0,0 +1,174 @@
1
+ 'use client';
2
+
3
+ // src/components/TextEditor/TextEditor.tsx
4
+ import { Field } from "@base-ui/react/field";
5
+ import {
6
+ useEffect,
7
+ useId,
8
+ useImperativeHandle,
9
+ useLayoutEffect,
10
+ useRef,
11
+ useState
12
+ } from "react";
13
+
14
+ // src/utils/cx.ts
15
+ function cx(...parts) {
16
+ return parts.filter(Boolean).join(" ");
17
+ }
18
+
19
+ // src/components/TextEditor/TextEditor.tsx
20
+ import { jsx, jsxs } from "react/jsx-runtime";
21
+ var load = () => import("./codemirror-6REE3TUN.js");
22
+ var isProse = (language) => language === "markdown" || language === "text";
23
+ function lineRange(text, line) {
24
+ const lines = text.split("\n");
25
+ const n = Math.min(Math.max(Math.trunc(line), 1), lines.length);
26
+ const start = lines.slice(0, n - 1).reduce((at, l) => at + l.length + 1, 0);
27
+ return [start, start + lines[n - 1].length];
28
+ }
29
+ function TextEditor({
30
+ label,
31
+ description,
32
+ error,
33
+ value,
34
+ onValueChange,
35
+ base,
36
+ language = "text",
37
+ liveDiff = false,
38
+ disabled = false,
39
+ readOnly = false,
40
+ rows = 8,
41
+ id: idProp,
42
+ className,
43
+ ref
44
+ }) {
45
+ const [module, setModule] = useState(null);
46
+ const textareaRef = useRef(null);
47
+ const hostRef = useRef(null);
48
+ const handleRef = useRef(null);
49
+ const handoffRef = useRef(null);
50
+ const onChangeRef = useRef(onValueChange);
51
+ useLayoutEffect(() => {
52
+ onChangeRef.current = onValueChange;
53
+ });
54
+ const generatedId = useId();
55
+ const id = idProp ?? generatedId;
56
+ const labelId = `${id}-label`;
57
+ const descriptionId = `${id}-description`;
58
+ const errorId = `${id}-error`;
59
+ const invalid = error != null;
60
+ const describedBy = [description != null && descriptionId, invalid && errorId].filter(Boolean).join(" ");
61
+ const options = {
62
+ value,
63
+ base,
64
+ liveDiff,
65
+ language,
66
+ disabled,
67
+ readOnly,
68
+ invalid,
69
+ attributes: {
70
+ id,
71
+ "aria-labelledby": labelId,
72
+ ...describedBy ? { "aria-describedby": describedBy } : {}
73
+ }
74
+ };
75
+ const optionsRef = useRef(options);
76
+ useLayoutEffect(() => {
77
+ optionsRef.current = options;
78
+ });
79
+ useEffect(() => {
80
+ let live = true;
81
+ void load().then((m) => {
82
+ if (!live) return;
83
+ const ta = textareaRef.current;
84
+ if (ta && document.activeElement === ta) handoffRef.current = [ta.selectionStart, ta.selectionEnd];
85
+ setModule(() => m);
86
+ });
87
+ return () => {
88
+ live = false;
89
+ };
90
+ }, []);
91
+ useLayoutEffect(() => {
92
+ if (!module || !hostRef.current) return;
93
+ const handle = module.mountEditor(hostRef.current, optionsRef.current, (v) => onChangeRef.current(v));
94
+ handleRef.current = handle;
95
+ if (handoffRef.current) handle.focus(...handoffRef.current);
96
+ handoffRef.current = null;
97
+ return () => {
98
+ handle.destroy();
99
+ handleRef.current = null;
100
+ };
101
+ }, [module]);
102
+ useLayoutEffect(() => {
103
+ handleRef.current?.update(options);
104
+ });
105
+ useImperativeHandle(
106
+ ref,
107
+ () => ({
108
+ focus() {
109
+ if (handleRef.current) return handleRef.current.focus();
110
+ if (!optionsRef.current.disabled) textareaRef.current?.focus();
111
+ },
112
+ selectLine(line) {
113
+ if (handleRef.current) return handleRef.current.selectLine(line);
114
+ const ta = textareaRef.current;
115
+ if (!ta || optionsRef.current.disabled) return;
116
+ ta.focus();
117
+ ta.setSelectionRange(...lineRange(ta.value, line));
118
+ }
119
+ }),
120
+ []
121
+ );
122
+ const prose = isProse(language);
123
+ return /* @__PURE__ */ jsxs(
124
+ Field.Root,
125
+ {
126
+ className: cx("sc-field", className),
127
+ disabled,
128
+ invalid,
129
+ "data-size": "md",
130
+ "data-editor": module ? "codemirror" : "fallback",
131
+ children: [
132
+ /* @__PURE__ */ jsx(
133
+ Field.Label,
134
+ {
135
+ id: labelId,
136
+ htmlFor: id,
137
+ className: "sc-field__label",
138
+ onClick: () => handleRef.current?.focus(),
139
+ children: label
140
+ }
141
+ ),
142
+ module ? /* @__PURE__ */ jsx("div", { ref: hostRef, style: { "--_rows": rows } }) : /* @__PURE__ */ jsx(
143
+ Field.Control,
144
+ {
145
+ id,
146
+ render: /* @__PURE__ */ jsx(
147
+ "textarea",
148
+ {
149
+ ref: textareaRef,
150
+ rows: Math.max(rows, value.split("\n").length),
151
+ style: { "--_rows": rows },
152
+ readOnly,
153
+ spellCheck: prose,
154
+ autoCorrect: prose ? "on" : "off",
155
+ autoCapitalize: prose ? "sentences" : "off"
156
+ }
157
+ ),
158
+ className: "sc-input sc-textarea",
159
+ "data-variant": "code",
160
+ "data-resize": "none",
161
+ value,
162
+ onValueChange: (v) => onValueChange(v)
163
+ }
164
+ ),
165
+ description != null ? /* @__PURE__ */ jsx(Field.Description, { id: descriptionId, className: "sc-field__description", children: description }) : null,
166
+ invalid ? /* @__PURE__ */ jsx(Field.Error, { id: errorId, className: "sc-field__error", match: true, children: error }) : null
167
+ ]
168
+ }
169
+ );
170
+ }
171
+ export {
172
+ TextEditor
173
+ };
174
+ //# sourceMappingURL=editor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/components/TextEditor/TextEditor.tsx","../src/utils/cx.ts"],"sourcesContent":["import { Field } from '@base-ui/react/field';\nimport {\n useEffect,\n useId,\n useImperativeHandle,\n useLayoutEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n type Ref,\n} from 'react';\nimport { cx } from '../../utils/cx';\nimport type * as CodeMirror from './codemirror';\nimport type { EditorHandle, EditorOptions, TextEditorLanguage } from './codemirror';\n\nexport type { TextEditorLanguage };\n\n/** What `ref` gives you: focus and line selection, on the Textarea before CodeMirror loads and on CodeMirror after. */\nexport interface TextEditorHandle {\n /** Focuses the editor. */\n focus(): void;\n /** Selects line `line` (1-based, clamped to the text), scrolls it into view and focuses the editor. */\n selectLine(line: number): void;\n}\n\nexport interface TextEditorProps {\n /** Required. The visible label. */\n label: ReactNode;\n /** Help text under the editor, wired with `aria-describedby`. */\n description?: ReactNode | undefined;\n /** The error message. When given the editor is invalid and the message is announced. */\n error?: ReactNode | undefined;\n /** The text. Controlled: feed `onValueChange`'s value back. */\n value: string;\n onValueChange: (value: string) => void;\n /** The text to compare against, such as the saved version. With a base, changes show inline; without, no diff. */\n base?: string | undefined;\n /**\n * Syntax highlighting, loaded per language. `markdown` and `text` (default) keep the phone's autocorrect,\n * capitals and spellcheck; `yaml` and `json` turn them off.\n */\n language?: TextEditorLanguage | undefined;\n /**\n * Keep showing changes while the editor has focus, updated 300ms after the last keystroke. Default false: the\n * diff hides while you type, so nothing moves under the caret, and shows again on blur.\n */\n liveDiff?: boolean | undefined;\n disabled?: boolean | undefined;\n /** Focusable and selectable, not editable. The phone's keyboard stays down. */\n readOnly?: boolean | undefined;\n /** Minimum visible lines; the editor grows with its content. Default 8. */\n rows?: number | undefined;\n /** The editable element's id (the Textarea's before CodeMirror loads). */\n id?: string | undefined;\n /** Class on the wrapper. */\n className?: string | undefined;\n /** Focus and select a line from outside, such as \"Show line 12\". */\n ref?: Ref<TextEditorHandle> | undefined;\n}\n\ntype CodeMirrorModule = typeof CodeMirror;\n// The only path to CodeMirror: a dynamic import, so it lands in its own chunk and never in the server render.\nconst load = (): Promise<CodeMirrorModule> => import('./codemirror');\n\nconst isProse = (language: TextEditorLanguage) => language === 'markdown' || language === 'text';\n\n/** Line `line`'s start and end offsets in `text`, without its line break; clamped like CodeMirror's. */\nfunction lineRange(text: string, line: number): [number, number] {\n const lines = text.split('\\n');\n const n = Math.min(Math.max(Math.trunc(line), 1), lines.length);\n const start = lines.slice(0, n - 1).reduce((at, l) => at + l.length + 1, 0);\n return [start, start + lines[n - 1]!.length];\n}\n\n/**\n * A code and text editor on CodeMirror 6, with inline word diffs against `base`. Client-only: the server and the\n * first client render show a code `<textarea>` in the same field chrome, and CodeMirror replaces it once its chunk\n * has loaded, keeping focus and the selection.\n */\nexport function TextEditor({\n label,\n description,\n error,\n value,\n onValueChange,\n base,\n language = 'text',\n liveDiff = false,\n disabled = false,\n readOnly = false,\n rows = 8,\n id: idProp,\n className,\n ref,\n}: TextEditorProps) {\n const [module, setModule] = useState<CodeMirrorModule | null>(null);\n const textareaRef = useRef<HTMLTextAreaElement>(null);\n const hostRef = useRef<HTMLDivElement>(null);\n const handleRef = useRef<EditorHandle | null>(null);\n // The Textarea's focus and selection at the moment of the swap.\n const handoffRef = useRef<[number, number] | null>(null);\n const onChangeRef = useRef(onValueChange);\n useLayoutEffect(() => {\n onChangeRef.current = onValueChange;\n });\n const generatedId = useId();\n const id = idProp ?? generatedId;\n const labelId = `${id}-label`;\n const descriptionId = `${id}-description`;\n const errorId = `${id}-error`;\n const invalid = error != null;\n const describedBy = [description != null && descriptionId, invalid && errorId].filter(Boolean).join(' ');\n const options: EditorOptions = {\n value,\n base,\n liveDiff,\n language,\n disabled,\n readOnly,\n invalid,\n attributes: {\n id,\n 'aria-labelledby': labelId,\n ...(describedBy ? { 'aria-describedby': describedBy } : {}),\n },\n };\n const optionsRef = useRef(options);\n useLayoutEffect(() => {\n optionsRef.current = options;\n });\n\n useEffect(() => {\n let live = true;\n void load().then((m) => {\n if (!live) return;\n const ta = textareaRef.current;\n if (ta && document.activeElement === ta) handoffRef.current = [ta.selectionStart, ta.selectionEnd];\n setModule(() => m);\n });\n return () => {\n live = false;\n };\n }, []);\n\n useLayoutEffect(() => {\n if (!module || !hostRef.current) return;\n const handle = module.mountEditor(hostRef.current, optionsRef.current, (v) => onChangeRef.current(v));\n handleRef.current = handle;\n if (handoffRef.current) handle.focus(...handoffRef.current);\n handoffRef.current = null;\n return () => {\n handle.destroy();\n handleRef.current = null;\n };\n }, [module]);\n\n useLayoutEffect(() => {\n handleRef.current?.update(options);\n });\n\n useImperativeHandle(\n ref,\n () => ({\n focus() {\n if (handleRef.current) return handleRef.current.focus();\n if (!optionsRef.current.disabled) textareaRef.current?.focus();\n },\n selectLine(line) {\n if (handleRef.current) return handleRef.current.selectLine(line);\n const ta = textareaRef.current;\n if (!ta || optionsRef.current.disabled) return;\n ta.focus();\n ta.setSelectionRange(...lineRange(ta.value, line));\n },\n }),\n [],\n );\n\n const prose = isProse(language);\n return (\n <Field.Root\n className={cx('sc-field', className)}\n disabled={disabled}\n invalid={invalid}\n data-size=\"md\"\n data-editor={module ? 'codemirror' : 'fallback'}\n >\n {/* A <label> can't point at a contenteditable, so once CodeMirror is in, a click focuses it by hand. */}\n <Field.Label\n id={labelId}\n htmlFor={id}\n className=\"sc-field__label\"\n onClick={() => handleRef.current?.focus()}\n >\n {label}\n </Field.Label>\n {module ? (\n <div ref={hostRef} style={{ '--_rows': rows } as CSSProperties} />\n ) : (\n <Field.Control\n id={id}\n render={\n <textarea\n ref={textareaRef}\n // Grows to its content like the editor (text-editor.css), so the swap moves the layout only by the diff.\n rows={Math.max(rows, value.split('\\n').length)}\n style={{ '--_rows': rows } as CSSProperties}\n readOnly={readOnly}\n spellCheck={prose}\n autoCorrect={prose ? 'on' : 'off'}\n autoCapitalize={prose ? 'sentences' : 'off'}\n />\n }\n className=\"sc-input sc-textarea\"\n data-variant=\"code\"\n data-resize=\"none\"\n value={value}\n onValueChange={(v) => onValueChange(v)}\n />\n )}\n {description != null ? (\n <Field.Description id={descriptionId} className=\"sc-field__description\">\n {description}\n </Field.Description>\n ) : null}\n {invalid ? (\n <Field.Error id={errorId} className=\"sc-field__error\" match>\n {error}\n </Field.Error>\n ) : null}\n </Field.Root>\n );\n}\n","/** Joins class names, dropping falsy entries. */\nexport function cx(...parts: Array<string | false | null | undefined>): string {\n return parts.filter(Boolean).join(' ');\n}\n"],"mappings":"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","names":[]}