@particle-academy/fancy-code 0.16.0 → 0.17.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/dist/index.d.cts CHANGED
@@ -21,6 +21,11 @@ interface UseEditorEngineReturn {
21
21
  themeColors: ThemeColors;
22
22
  handleKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
23
23
  handleInput: () => void;
24
+ /**
25
+ * Ends the focus session: disarms Tab-indent so the next Tab into the editor
26
+ * moves focus rather than indenting. Wire to the textarea's `onBlur`.
27
+ */
28
+ handleBlur: () => void;
24
29
  handleScroll: () => void;
25
30
  handleSelect: () => void;
26
31
  /**
@@ -49,13 +54,22 @@ interface CodeEditorProps {
49
54
  onLanguageChange?: (lang: string) => void;
50
55
  /** Theme name: "light", "dark", "auto", or a custom registered name (default: "auto") */
51
56
  theme?: string;
52
- /** Prevent editing (default: false) */
57
+ /**
58
+ * Prevent editing (default: false). Read-only editors still take every
59
+ * navigation key, and Tab / Shift+Tab move focus out.
60
+ */
53
61
  readOnly?: boolean;
54
62
  /** Show line numbers (default: true) */
55
63
  lineNumbers?: boolean;
56
64
  /** Enable word wrap (default: false) */
57
65
  wordWrap?: boolean;
58
- /** Tab size in spaces (default: 2) */
66
+ /**
67
+ * Tab size in spaces (default: 2).
68
+ *
69
+ * Tab / Shift+Tab indent and dedent only once the user has edited in the
70
+ * current focus session; until then Tab moves focus like any other control,
71
+ * so keyboard users are never trapped (WCAG 2.1.2). Escape and blur disarm.
72
+ */
59
73
  tabSize?: number;
60
74
  /** Placeholder shown when empty */
61
75
  placeholder?: string;
@@ -88,7 +102,19 @@ interface CodeEditorProps {
88
102
  * marks. Requires `lineNumbers`.
89
103
  */
90
104
  diffBase?: string | null;
105
+ /**
106
+ * Accessible name of the editing surface. Lands on the `<textarea>` a person
107
+ * types into, not on a wrapper. Default "Code editor"; ignored when
108
+ * `aria-labelledby` is set.
109
+ */
110
+ "aria-label"?: string;
111
+ /** Id(s) of the element(s) naming the editing surface. Wins over `aria-label`. */
112
+ "aria-labelledby"?: string;
113
+ /** Id(s) of the element(s) describing the editing surface. */
114
+ "aria-describedby"?: string;
91
115
  }
116
+ /** The accessible-name props every editor forwards to its textarea. */
117
+ type EditorAriaProps = Pick<CodeEditorProps, "aria-label" | "aria-labelledby" | "aria-describedby">;
92
118
  interface CodeEditorContextValue {
93
119
  /** Get the current document text */
94
120
  getValue: () => string;
@@ -166,6 +192,12 @@ interface CodeEditorContextValue {
166
192
  _maxHeight?: number;
167
193
  /** @internal — lines to band; see {@link CodeEditorProps.highlightLines}. */
168
194
  _highlightLines?: readonly number[];
195
+ /** @internal — accessible-name attributes for the textarea. */
196
+ _aria?: {
197
+ "aria-label"?: string;
198
+ "aria-labelledby"?: string;
199
+ "aria-describedby"?: string;
200
+ };
169
201
  }
170
202
  interface CodeEditorToolbarProps {
171
203
  children?: ReactNode;
@@ -199,7 +231,7 @@ declare namespace CodeEditorStatusBar {
199
231
  var displayName: string;
200
232
  }
201
233
 
202
- declare function CodeEditorRoot({ children, className, value: valueProp, defaultValue, onChange, language: languageProp, onLanguageChange, theme, readOnly, lineNumbers: lineNumbersProp, wordWrap: wordWrapProp, tabSize: tabSizeProp, placeholder, minHeight, maxHeight, cursorLine, cursorColumn, diffBase, highlightLines, }: CodeEditorProps): react.JSX.Element;
234
+ declare function CodeEditorRoot({ children, className, value: valueProp, defaultValue, onChange, language: languageProp, onLanguageChange, theme, readOnly, lineNumbers: lineNumbersProp, wordWrap: wordWrapProp, tabSize: tabSizeProp, placeholder, minHeight, maxHeight, cursorLine, cursorColumn, diffBase, highlightLines, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, }: CodeEditorProps): react.JSX.Element;
203
235
  declare namespace CodeEditorRoot {
204
236
  var displayName: string;
205
237
  }
@@ -214,7 +246,11 @@ declare const CodeEditor: typeof CodeEditorRoot & {
214
246
  declare function useCodeEditor(): CodeEditorContextValue;
215
247
 
216
248
  type MarkdownEditorMode = "split" | "edit" | "preview";
217
- interface MarkdownEditorProps {
249
+ /**
250
+ * `aria-label` / `aria-labelledby` / `aria-describedby` name the editing
251
+ * textarea. Default name: "Markdown editor".
252
+ */
253
+ interface MarkdownEditorProps extends EditorAriaProps {
218
254
  /** Controlled markdown value. */
219
255
  value?: string;
220
256
  /** Initial value (uncontrolled). */
@@ -265,7 +301,7 @@ interface MarkdownEditorProps {
265
301
  * <MarkdownEditor value={md} onValueChange={setMd} mode="split" minHeight={240} />
266
302
  * ```
267
303
  */
268
- declare function MarkdownEditor({ value, defaultValue, onValueChange, mode, theme, readOnly, lineNumbers, wordWrap, placeholder, minHeight, maxHeight, className, renderPreview, }: MarkdownEditorProps): react.JSX.Element;
304
+ declare function MarkdownEditor({ value, defaultValue, onValueChange, mode, theme, readOnly, lineNumbers, wordWrap, placeholder, minHeight, maxHeight, className, renderPreview, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, }: MarkdownEditorProps): react.JSX.Element;
269
305
  declare namespace MarkdownEditor {
270
306
  var displayName: string;
271
307
  }
@@ -319,7 +355,11 @@ interface ResolveFileKindInput {
319
355
  */
320
356
  declare function resolveFileKind({ filename, mime }: ResolveFileKindInput): FileKind;
321
357
 
322
- interface FileViewerProps {
358
+ /**
359
+ * `aria-label` / `aria-labelledby` / `aria-describedby` name the source view's
360
+ * textarea. Default name: the `filename`, else "File contents".
361
+ */
362
+ interface FileViewerProps extends EditorAriaProps {
323
363
  /**
324
364
  * File name or path — used to detect the file kind / editor language, and as
325
365
  * a label / alt text. Pass `mime` too when you have it (it's preferred).
@@ -405,7 +445,7 @@ interface FileViewerProps {
405
445
  * <FileViewer filename="logo.png" src={url} style={{ height: 360 }} />
406
446
  * ```
407
447
  */
408
- declare function FileViewer({ filename, mime, src, value, onChange, readOnly, theme, fileKind, lineNumbers, wordWrap, line, column, highlightLines, minHeight, maxHeight, mediaProps, view, frameProps, document, className, style, }: FileViewerProps): react.JSX.Element;
448
+ declare function FileViewer({ filename, mime, src, value, onChange, readOnly, theme, fileKind, lineNumbers, wordWrap, line, column, highlightLines, minHeight, maxHeight, mediaProps, view, frameProps, document, className, style, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, }: FileViewerProps): react.JSX.Element;
409
449
  declare namespace FileViewer {
410
450
  var displayName: string;
411
451
  }
package/dist/index.d.ts CHANGED
@@ -21,6 +21,11 @@ interface UseEditorEngineReturn {
21
21
  themeColors: ThemeColors;
22
22
  handleKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
23
23
  handleInput: () => void;
24
+ /**
25
+ * Ends the focus session: disarms Tab-indent so the next Tab into the editor
26
+ * moves focus rather than indenting. Wire to the textarea's `onBlur`.
27
+ */
28
+ handleBlur: () => void;
24
29
  handleScroll: () => void;
25
30
  handleSelect: () => void;
26
31
  /**
@@ -49,13 +54,22 @@ interface CodeEditorProps {
49
54
  onLanguageChange?: (lang: string) => void;
50
55
  /** Theme name: "light", "dark", "auto", or a custom registered name (default: "auto") */
51
56
  theme?: string;
52
- /** Prevent editing (default: false) */
57
+ /**
58
+ * Prevent editing (default: false). Read-only editors still take every
59
+ * navigation key, and Tab / Shift+Tab move focus out.
60
+ */
53
61
  readOnly?: boolean;
54
62
  /** Show line numbers (default: true) */
55
63
  lineNumbers?: boolean;
56
64
  /** Enable word wrap (default: false) */
57
65
  wordWrap?: boolean;
58
- /** Tab size in spaces (default: 2) */
66
+ /**
67
+ * Tab size in spaces (default: 2).
68
+ *
69
+ * Tab / Shift+Tab indent and dedent only once the user has edited in the
70
+ * current focus session; until then Tab moves focus like any other control,
71
+ * so keyboard users are never trapped (WCAG 2.1.2). Escape and blur disarm.
72
+ */
59
73
  tabSize?: number;
60
74
  /** Placeholder shown when empty */
61
75
  placeholder?: string;
@@ -88,7 +102,19 @@ interface CodeEditorProps {
88
102
  * marks. Requires `lineNumbers`.
89
103
  */
90
104
  diffBase?: string | null;
105
+ /**
106
+ * Accessible name of the editing surface. Lands on the `<textarea>` a person
107
+ * types into, not on a wrapper. Default "Code editor"; ignored when
108
+ * `aria-labelledby` is set.
109
+ */
110
+ "aria-label"?: string;
111
+ /** Id(s) of the element(s) naming the editing surface. Wins over `aria-label`. */
112
+ "aria-labelledby"?: string;
113
+ /** Id(s) of the element(s) describing the editing surface. */
114
+ "aria-describedby"?: string;
91
115
  }
116
+ /** The accessible-name props every editor forwards to its textarea. */
117
+ type EditorAriaProps = Pick<CodeEditorProps, "aria-label" | "aria-labelledby" | "aria-describedby">;
92
118
  interface CodeEditorContextValue {
93
119
  /** Get the current document text */
94
120
  getValue: () => string;
@@ -166,6 +192,12 @@ interface CodeEditorContextValue {
166
192
  _maxHeight?: number;
167
193
  /** @internal — lines to band; see {@link CodeEditorProps.highlightLines}. */
168
194
  _highlightLines?: readonly number[];
195
+ /** @internal — accessible-name attributes for the textarea. */
196
+ _aria?: {
197
+ "aria-label"?: string;
198
+ "aria-labelledby"?: string;
199
+ "aria-describedby"?: string;
200
+ };
169
201
  }
170
202
  interface CodeEditorToolbarProps {
171
203
  children?: ReactNode;
@@ -199,7 +231,7 @@ declare namespace CodeEditorStatusBar {
199
231
  var displayName: string;
200
232
  }
201
233
 
202
- declare function CodeEditorRoot({ children, className, value: valueProp, defaultValue, onChange, language: languageProp, onLanguageChange, theme, readOnly, lineNumbers: lineNumbersProp, wordWrap: wordWrapProp, tabSize: tabSizeProp, placeholder, minHeight, maxHeight, cursorLine, cursorColumn, diffBase, highlightLines, }: CodeEditorProps): react.JSX.Element;
234
+ declare function CodeEditorRoot({ children, className, value: valueProp, defaultValue, onChange, language: languageProp, onLanguageChange, theme, readOnly, lineNumbers: lineNumbersProp, wordWrap: wordWrapProp, tabSize: tabSizeProp, placeholder, minHeight, maxHeight, cursorLine, cursorColumn, diffBase, highlightLines, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, }: CodeEditorProps): react.JSX.Element;
203
235
  declare namespace CodeEditorRoot {
204
236
  var displayName: string;
205
237
  }
@@ -214,7 +246,11 @@ declare const CodeEditor: typeof CodeEditorRoot & {
214
246
  declare function useCodeEditor(): CodeEditorContextValue;
215
247
 
216
248
  type MarkdownEditorMode = "split" | "edit" | "preview";
217
- interface MarkdownEditorProps {
249
+ /**
250
+ * `aria-label` / `aria-labelledby` / `aria-describedby` name the editing
251
+ * textarea. Default name: "Markdown editor".
252
+ */
253
+ interface MarkdownEditorProps extends EditorAriaProps {
218
254
  /** Controlled markdown value. */
219
255
  value?: string;
220
256
  /** Initial value (uncontrolled). */
@@ -265,7 +301,7 @@ interface MarkdownEditorProps {
265
301
  * <MarkdownEditor value={md} onValueChange={setMd} mode="split" minHeight={240} />
266
302
  * ```
267
303
  */
268
- declare function MarkdownEditor({ value, defaultValue, onValueChange, mode, theme, readOnly, lineNumbers, wordWrap, placeholder, minHeight, maxHeight, className, renderPreview, }: MarkdownEditorProps): react.JSX.Element;
304
+ declare function MarkdownEditor({ value, defaultValue, onValueChange, mode, theme, readOnly, lineNumbers, wordWrap, placeholder, minHeight, maxHeight, className, renderPreview, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, }: MarkdownEditorProps): react.JSX.Element;
269
305
  declare namespace MarkdownEditor {
270
306
  var displayName: string;
271
307
  }
@@ -319,7 +355,11 @@ interface ResolveFileKindInput {
319
355
  */
320
356
  declare function resolveFileKind({ filename, mime }: ResolveFileKindInput): FileKind;
321
357
 
322
- interface FileViewerProps {
358
+ /**
359
+ * `aria-label` / `aria-labelledby` / `aria-describedby` name the source view's
360
+ * textarea. Default name: the `filename`, else "File contents".
361
+ */
362
+ interface FileViewerProps extends EditorAriaProps {
323
363
  /**
324
364
  * File name or path — used to detect the file kind / editor language, and as
325
365
  * a label / alt text. Pass `mime` too when you have it (it's preferred).
@@ -405,7 +445,7 @@ interface FileViewerProps {
405
445
  * <FileViewer filename="logo.png" src={url} style={{ height: 360 }} />
406
446
  * ```
407
447
  */
408
- declare function FileViewer({ filename, mime, src, value, onChange, readOnly, theme, fileKind, lineNumbers, wordWrap, line, column, highlightLines, minHeight, maxHeight, mediaProps, view, frameProps, document, className, style, }: FileViewerProps): react.JSX.Element;
448
+ declare function FileViewer({ filename, mime, src, value, onChange, readOnly, theme, fileKind, lineNumbers, wordWrap, line, column, highlightLines, minHeight, maxHeight, mediaProps, view, frameProps, document, className, style, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, }: FileViewerProps): react.JSX.Element;
409
449
  declare namespace FileViewer {
410
450
  var displayName: string;
411
451
  }
package/dist/index.js CHANGED
@@ -41,7 +41,8 @@ function CodeEditorPanel({ className }) {
41
41
  _diffAnnotations: diffAnnotations,
42
42
  _maxHeight,
43
43
  _minHeight,
44
- _highlightLines: highlightLines
44
+ _highlightLines: highlightLines,
45
+ _aria: aria
45
46
  } = useCodeEditor();
46
47
  const lineNumberElements = useMemo(() => {
47
48
  if (!engine || !lineNumbers) return null;
@@ -131,6 +132,7 @@ function CodeEditorPanel({ className }) {
131
132
  themeColors,
132
133
  handleKeyDown,
133
134
  handleInput,
135
+ handleBlur,
134
136
  handleScroll,
135
137
  handleSelect,
136
138
  scrollTop,
@@ -203,8 +205,12 @@ function CodeEditorPanel({ className }) {
203
205
  "textarea",
204
206
  {
205
207
  ref: textareaRef,
208
+ "aria-label": aria?.["aria-label"],
209
+ "aria-labelledby": aria?.["aria-labelledby"],
210
+ "aria-describedby": aria?.["aria-describedby"],
206
211
  className: cn(
207
- "relative m-0 block resize-none border-none bg-transparent p-2.5 text-[13px] leading-[1.5] text-transparent outline-none",
212
+ "relative m-0 block resize-none border-none bg-transparent p-2.5 text-[13px] leading-[1.5] text-transparent",
213
+ "focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[color:var(--fancy-focus,var(--color-blue-500))] dark:focus-visible:ring-[color:var(--fancy-focus-dark,var(--color-blue-400))]",
208
214
  wordWrap ? "w-full" : "min-w-full"
209
215
  ),
210
216
  style: {
@@ -221,6 +227,7 @@ function CodeEditorPanel({ className }) {
221
227
  readOnly,
222
228
  onKeyDown: handleKeyDown,
223
229
  onInput: handleInput,
230
+ onBlur: handleBlur,
224
231
  onScroll: handleScroll,
225
232
  onSelect: handleSelect,
226
233
  onClick: handleSelect
@@ -482,6 +489,7 @@ function useEditorEngine({
482
489
  const [scrollTop, setScrollTop] = useState(0);
483
490
  const [scrollLeft, setScrollLeft] = useState(0);
484
491
  const [activeLine, setActiveLine] = useState(1);
492
+ const tabArmedRef = useRef(false);
485
493
  const themeColors = useMemo(() => {
486
494
  const def = getTheme(theme);
487
495
  return def?.colors ?? DEFAULT_COLORS;
@@ -542,6 +550,7 @@ function useEditorEngine({
542
550
  const handleInput = useCallback(() => {
543
551
  const ta = textareaRef.current;
544
552
  if (!ta) return;
553
+ tabArmedRef.current = true;
545
554
  onChangeRef.current?.(ta.value);
546
555
  autoResize();
547
556
  updateCursorInfo();
@@ -577,14 +586,19 @@ function useEditorEngine({
577
586
  },
578
587
  [updateCursorInfo]
579
588
  );
589
+ const handleBlur = useCallback(() => {
590
+ tabArmedRef.current = false;
591
+ }, []);
580
592
  const handleKeyDown = useCallback(
581
593
  (e) => {
582
594
  if (readOnly) {
583
- if (e.key === "c" && (e.metaKey || e.ctrlKey)) return;
584
- if (e.key === "a" && (e.metaKey || e.ctrlKey)) return;
585
- e.preventDefault();
586
595
  return;
587
596
  }
597
+ if (e.key === "Escape") {
598
+ tabArmedRef.current = false;
599
+ return;
600
+ }
601
+ if (e.key === "Tab" && !tabArmedRef.current) return;
588
602
  if (e.key === "Tab") {
589
603
  e.preventDefault();
590
604
  const ta = e.currentTarget;
@@ -633,6 +647,7 @@ function useEditorEngine({
633
647
  }
634
648
  if (e.key === "Enter") {
635
649
  e.preventDefault();
650
+ tabArmedRef.current = true;
636
651
  const ta = e.currentTarget;
637
652
  const start = ta.selectionStart;
638
653
  const end = ta.selectionEnd;
@@ -661,6 +676,7 @@ function useEditorEngine({
661
676
  themeColors,
662
677
  handleKeyDown,
663
678
  handleInput,
679
+ handleBlur,
664
680
  handleScroll,
665
681
  handleSelect,
666
682
  revealLine,
@@ -701,8 +717,19 @@ function CodeEditorRoot({
701
717
  cursorLine,
702
718
  cursorColumn,
703
719
  diffBase,
704
- highlightLines
720
+ highlightLines,
721
+ "aria-label": ariaLabel,
722
+ "aria-labelledby": ariaLabelledBy,
723
+ "aria-describedby": ariaDescribedBy
705
724
  }) {
725
+ const aria = useMemo(
726
+ () => ({
727
+ "aria-label": ariaLabelledBy ? void 0 : ariaLabel ?? "Code editor",
728
+ "aria-labelledby": ariaLabelledBy,
729
+ "aria-describedby": ariaDescribedBy
730
+ }),
731
+ [ariaLabel, ariaLabelledBy, ariaDescribedBy]
732
+ );
706
733
  const [currentValue, setCurrentValue] = useControllableState(valueProp, defaultValue, onChange);
707
734
  const diffAnnotations = useMemo(
708
735
  () => diffBase == null ? null : annotateLines(diffBase, currentValue),
@@ -795,9 +822,10 @@ function CodeEditorRoot({
795
822
  _engineReturn: engineReturn,
796
823
  _minHeight: minHeight,
797
824
  _maxHeight: maxHeight,
798
- _highlightLines: highlightLines
825
+ _highlightLines: highlightLines,
826
+ _aria: aria
799
827
  }),
800
- [engineReturn, currentValue, currentLanguage, setLanguage, resolvedTheme, readOnly, showLineNumbers, isWordWrap, tabSizeProp, cursorPosition, selectionLength, setCurrentValue, placeholder, diffAnnotations, minHeight, maxHeight, highlightLines]
828
+ [engineReturn, currentValue, currentLanguage, setLanguage, resolvedTheme, readOnly, showLineNumbers, isWordWrap, tabSizeProp, cursorPosition, selectionLength, setCurrentValue, placeholder, diffAnnotations, minHeight, maxHeight, highlightLines, aria]
801
829
  );
802
830
  return /* @__PURE__ */ jsx(CodeEditorContext.Provider, { value: contextValue, children: /* @__PURE__ */ jsx(
803
831
  "div",
@@ -833,7 +861,10 @@ function MarkdownEditor({
833
861
  minHeight,
834
862
  maxHeight,
835
863
  className,
836
- renderPreview
864
+ renderPreview,
865
+ "aria-label": ariaLabel,
866
+ "aria-labelledby": ariaLabelledBy,
867
+ "aria-describedby": ariaDescribedBy
837
868
  }) {
838
869
  const [content, setContent] = useControllableState(value, defaultValue, onValueChange);
839
870
  const showEditor = mode !== "preview";
@@ -858,6 +889,9 @@ function MarkdownEditor({
858
889
  placeholder,
859
890
  minHeight,
860
891
  maxHeight,
892
+ "aria-label": ariaLabel ?? "Markdown editor",
893
+ "aria-labelledby": ariaLabelledBy,
894
+ "aria-describedby": ariaDescribedBy,
861
895
  children: /* @__PURE__ */ jsx(CodeEditor.Panel, {})
862
896
  }
863
897
  ) }),
@@ -959,7 +993,10 @@ function FileViewer({
959
993
  frameProps,
960
994
  document,
961
995
  className,
962
- style
996
+ style,
997
+ "aria-label": ariaLabel,
998
+ "aria-labelledby": ariaLabelledBy,
999
+ "aria-describedby": ariaDescribedBy
963
1000
  }) {
964
1001
  const resolved = fileKind ?? resolveFileKind({ filename, mime });
965
1002
  if (resolved.kind === "document" && view === "rendered" && readOnly && line == null) {
@@ -1053,6 +1090,9 @@ function FileViewer({
1053
1090
  cursorLine: line,
1054
1091
  cursorColumn: column,
1055
1092
  highlightLines: highlightLines ?? (line != null ? [line] : void 0),
1093
+ "aria-label": ariaLabel ?? filename ?? "File contents",
1094
+ "aria-labelledby": ariaLabelledBy,
1095
+ "aria-describedby": ariaDescribedBy,
1056
1096
  children: /* @__PURE__ */ jsx(CodeEditor.Panel, {})
1057
1097
  }
1058
1098
  )