@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/CHANGELOG.md CHANGED
@@ -14,6 +14,44 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.17.0] - 2026-11-13
18
+
19
+ ### Changed
20
+
21
+ - **Releases need a lab-run certificate.** `publish.yml` gains a read-only
22
+ `lab-certificate` job that every publishing job needs: a tag is published only
23
+ when the fancy-labs lab has certified that exact version and commit. Consumers
24
+ do nothing; this changes how releases are cut, not what the package does.
25
+
26
+ ### Fixed
27
+
28
+ - **The editing textarea of `CodeEditor`, `FileViewer` and `MarkdownEditor`
29
+ shows a focus ring.** It was `outline-none` with nothing in its place, so a
30
+ keyboard user could not see where focus was. It now carries the kit ring
31
+ (`focus-visible:ring-2 ring-inset`, `--fancy-focus` / `--fancy-focus-dark`) on
32
+ the textarea itself. Consumers do nothing.
33
+ - **`FileViewer` (and any `readOnly` editor) no longer traps keyboard focus.**
34
+ Its key handler called `preventDefault` on every key except copy and
35
+ select-all, Tab included, so focus could never leave and the caret could not
36
+ move. Read-only now intercepts nothing; the textarea's `readOnly` attribute
37
+ already refuses edits.
38
+ - **Editable `CodeEditor` and `MarkdownEditor` no longer trap keyboard focus
39
+ (WCAG 2.1.2).** Tab always inserted indentation, so tabbing in meant never
40
+ tabbing out. Tab / Shift+Tab now indent and dedent only once you have edited
41
+ in the current focus session (typed, pasted, pressed Enter); until then Tab
42
+ moves focus on. Escape disarms, so the next Tab leaves; blur disarms too.
43
+ **Behaviour change:** a Tab pressed straight after focusing the editor, with
44
+ no edit yet, now moves focus instead of indenting. Nothing to do unless you
45
+ relied on that; type first, and Tab indents as before.
46
+ - **`CodeEditor`, `FileViewer` and `MarkdownEditor` can be named, and are named
47
+ by default** (axe `label`, critical, lab run 601, K28). The `<textarea>` a
48
+ person types into had no accessible name and none of the three accepted one.
49
+ Each now takes `aria-label`, `aria-labelledby` and `aria-describedby`, and
50
+ puts them on that textarea, not on a wrapper. Defaults: "Code editor",
51
+ the `filename` (else "File contents") for `FileViewer`, and "Markdown
52
+ editor". A `aria-labelledby` wins and suppresses the default label. Nothing
53
+ to do on upgrade; pass a name that says what the field is for, where you can.
54
+
17
55
  ## [0.16.0] - 2026-11-02
18
56
 
19
57
  ### Added
@@ -197,7 +235,6 @@ renders exactly as before.
197
235
 
198
236
  These are the kit 0.5 platform floors, applied across every package at once so a consumer never has to resolve a mix. **No API changed, nothing was removed, nothing was renamed** — only what the package requires.
199
237
 
200
-
201
238
  ## [0.9.1] — 2026-07-28
202
239
 
203
240
  ### Fixed
package/dist/index.cjs CHANGED
@@ -42,7 +42,8 @@ function CodeEditorPanel({ className }) {
42
42
  _diffAnnotations: diffAnnotations,
43
43
  _maxHeight,
44
44
  _minHeight,
45
- _highlightLines: highlightLines
45
+ _highlightLines: highlightLines,
46
+ _aria: aria
46
47
  } = useCodeEditor();
47
48
  const lineNumberElements = react.useMemo(() => {
48
49
  if (!engine || !lineNumbers) return null;
@@ -132,6 +133,7 @@ function CodeEditorPanel({ className }) {
132
133
  themeColors,
133
134
  handleKeyDown,
134
135
  handleInput,
136
+ handleBlur,
135
137
  handleScroll,
136
138
  handleSelect,
137
139
  scrollTop,
@@ -204,8 +206,12 @@ function CodeEditorPanel({ className }) {
204
206
  "textarea",
205
207
  {
206
208
  ref: textareaRef,
209
+ "aria-label": aria?.["aria-label"],
210
+ "aria-labelledby": aria?.["aria-labelledby"],
211
+ "aria-describedby": aria?.["aria-describedby"],
207
212
  className: reactFancy.cn(
208
- "relative m-0 block resize-none border-none bg-transparent p-2.5 text-[13px] leading-[1.5] text-transparent outline-none",
213
+ "relative m-0 block resize-none border-none bg-transparent p-2.5 text-[13px] leading-[1.5] text-transparent",
214
+ "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))]",
209
215
  wordWrap ? "w-full" : "min-w-full"
210
216
  ),
211
217
  style: {
@@ -222,6 +228,7 @@ function CodeEditorPanel({ className }) {
222
228
  readOnly,
223
229
  onKeyDown: handleKeyDown,
224
230
  onInput: handleInput,
231
+ onBlur: handleBlur,
225
232
  onScroll: handleScroll,
226
233
  onSelect: handleSelect,
227
234
  onClick: handleSelect
@@ -483,6 +490,7 @@ function useEditorEngine({
483
490
  const [scrollTop, setScrollTop] = react.useState(0);
484
491
  const [scrollLeft, setScrollLeft] = react.useState(0);
485
492
  const [activeLine, setActiveLine] = react.useState(1);
493
+ const tabArmedRef = react.useRef(false);
486
494
  const themeColors = react.useMemo(() => {
487
495
  const def = getTheme(theme);
488
496
  return def?.colors ?? DEFAULT_COLORS;
@@ -543,6 +551,7 @@ function useEditorEngine({
543
551
  const handleInput = react.useCallback(() => {
544
552
  const ta = textareaRef.current;
545
553
  if (!ta) return;
554
+ tabArmedRef.current = true;
546
555
  onChangeRef.current?.(ta.value);
547
556
  autoResize();
548
557
  updateCursorInfo();
@@ -578,14 +587,19 @@ function useEditorEngine({
578
587
  },
579
588
  [updateCursorInfo]
580
589
  );
590
+ const handleBlur = react.useCallback(() => {
591
+ tabArmedRef.current = false;
592
+ }, []);
581
593
  const handleKeyDown = react.useCallback(
582
594
  (e) => {
583
595
  if (readOnly) {
584
- if (e.key === "c" && (e.metaKey || e.ctrlKey)) return;
585
- if (e.key === "a" && (e.metaKey || e.ctrlKey)) return;
586
- e.preventDefault();
587
596
  return;
588
597
  }
598
+ if (e.key === "Escape") {
599
+ tabArmedRef.current = false;
600
+ return;
601
+ }
602
+ if (e.key === "Tab" && !tabArmedRef.current) return;
589
603
  if (e.key === "Tab") {
590
604
  e.preventDefault();
591
605
  const ta = e.currentTarget;
@@ -634,6 +648,7 @@ function useEditorEngine({
634
648
  }
635
649
  if (e.key === "Enter") {
636
650
  e.preventDefault();
651
+ tabArmedRef.current = true;
637
652
  const ta = e.currentTarget;
638
653
  const start = ta.selectionStart;
639
654
  const end = ta.selectionEnd;
@@ -662,6 +677,7 @@ function useEditorEngine({
662
677
  themeColors,
663
678
  handleKeyDown,
664
679
  handleInput,
680
+ handleBlur,
665
681
  handleScroll,
666
682
  handleSelect,
667
683
  revealLine,
@@ -702,8 +718,19 @@ function CodeEditorRoot({
702
718
  cursorLine,
703
719
  cursorColumn,
704
720
  diffBase,
705
- highlightLines
721
+ highlightLines,
722
+ "aria-label": ariaLabel,
723
+ "aria-labelledby": ariaLabelledBy,
724
+ "aria-describedby": ariaDescribedBy
706
725
  }) {
726
+ const aria = react.useMemo(
727
+ () => ({
728
+ "aria-label": ariaLabelledBy ? void 0 : ariaLabel ?? "Code editor",
729
+ "aria-labelledby": ariaLabelledBy,
730
+ "aria-describedby": ariaDescribedBy
731
+ }),
732
+ [ariaLabel, ariaLabelledBy, ariaDescribedBy]
733
+ );
707
734
  const [currentValue, setCurrentValue] = reactFancy.useControllableState(valueProp, defaultValue, onChange);
708
735
  const diffAnnotations = react.useMemo(
709
736
  () => diffBase == null ? null : fancyFileCommons.annotateLines(diffBase, currentValue),
@@ -796,9 +823,10 @@ function CodeEditorRoot({
796
823
  _engineReturn: engineReturn,
797
824
  _minHeight: minHeight,
798
825
  _maxHeight: maxHeight,
799
- _highlightLines: highlightLines
826
+ _highlightLines: highlightLines,
827
+ _aria: aria
800
828
  }),
801
- [engineReturn, currentValue, currentLanguage, setLanguage, resolvedTheme, readOnly, showLineNumbers, isWordWrap, tabSizeProp, cursorPosition, selectionLength, setCurrentValue, placeholder, diffAnnotations, minHeight, maxHeight, highlightLines]
829
+ [engineReturn, currentValue, currentLanguage, setLanguage, resolvedTheme, readOnly, showLineNumbers, isWordWrap, tabSizeProp, cursorPosition, selectionLength, setCurrentValue, placeholder, diffAnnotations, minHeight, maxHeight, highlightLines, aria]
802
830
  );
803
831
  return /* @__PURE__ */ jsxRuntime.jsx(CodeEditorContext.Provider, { value: contextValue, children: /* @__PURE__ */ jsxRuntime.jsx(
804
832
  "div",
@@ -834,7 +862,10 @@ function MarkdownEditor({
834
862
  minHeight,
835
863
  maxHeight,
836
864
  className,
837
- renderPreview
865
+ renderPreview,
866
+ "aria-label": ariaLabel,
867
+ "aria-labelledby": ariaLabelledBy,
868
+ "aria-describedby": ariaDescribedBy
838
869
  }) {
839
870
  const [content, setContent] = reactFancy.useControllableState(value, defaultValue, onValueChange);
840
871
  const showEditor = mode !== "preview";
@@ -859,6 +890,9 @@ function MarkdownEditor({
859
890
  placeholder,
860
891
  minHeight,
861
892
  maxHeight,
893
+ "aria-label": ariaLabel ?? "Markdown editor",
894
+ "aria-labelledby": ariaLabelledBy,
895
+ "aria-describedby": ariaDescribedBy,
862
896
  children: /* @__PURE__ */ jsxRuntime.jsx(CodeEditor.Panel, {})
863
897
  }
864
898
  ) }),
@@ -960,7 +994,10 @@ function FileViewer({
960
994
  frameProps,
961
995
  document,
962
996
  className,
963
- style
997
+ style,
998
+ "aria-label": ariaLabel,
999
+ "aria-labelledby": ariaLabelledBy,
1000
+ "aria-describedby": ariaDescribedBy
964
1001
  }) {
965
1002
  const resolved = fileKind ?? resolveFileKind({ filename, mime });
966
1003
  if (resolved.kind === "document" && view === "rendered" && readOnly && line == null) {
@@ -1054,6 +1091,9 @@ function FileViewer({
1054
1091
  cursorLine: line,
1055
1092
  cursorColumn: column,
1056
1093
  highlightLines: highlightLines ?? (line != null ? [line] : void 0),
1094
+ "aria-label": ariaLabel ?? filename ?? "File contents",
1095
+ "aria-labelledby": ariaLabelledBy,
1096
+ "aria-describedby": ariaDescribedBy,
1057
1097
  children: /* @__PURE__ */ jsxRuntime.jsx(CodeEditor.Panel, {})
1058
1098
  }
1059
1099
  )