drawnui-react 0.1.0-preview.11 → 0.1.0-preview.13

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.
Files changed (38) hide show
  1. package/SKIPPED.md +7 -6
  2. package/dist/controls/GridStructure.d.ts +4 -2
  3. package/dist/controls/GridStructure.js +2 -2
  4. package/dist/controls/SkiaButton.d.ts +2 -0
  5. package/dist/controls/SkiaButton.js +2 -0
  6. package/dist/controls/SkiaEditor.d.ts +7 -0
  7. package/dist/controls/SkiaEditor.js +9 -1
  8. package/dist/controls/SkiaGif.js +1 -1
  9. package/dist/controls/SkiaLabelFps.d.ts +16 -0
  10. package/dist/controls/SkiaLabelFps.js +41 -0
  11. package/dist/controls/SkiaLayout.d.ts +34 -7
  12. package/dist/controls/SkiaLayout.js +174 -12
  13. package/dist/controls/SkiaLottie.js +1 -1
  14. package/dist/controls/SkiaScroll.d.ts +5 -4
  15. package/dist/controls/SkiaScroll.js +17 -8
  16. package/dist/controls/SkiaSlider.d.ts +8 -0
  17. package/dist/controls/SkiaSlider.js +39 -0
  18. package/dist/controls/SkiaSvg.js +1 -1
  19. package/dist/core/Accessibility.d.ts +59 -0
  20. package/dist/core/Accessibility.js +303 -3
  21. package/dist/core/Canvas.d.ts +6 -0
  22. package/dist/core/Canvas.js +9 -1
  23. package/dist/core/SkiaControl.d.ts +21 -1
  24. package/dist/core/SkiaControl.js +28 -2
  25. package/dist/core/SkiaEffect.js +1 -1
  26. package/dist/core/SkiaImageManager.js +1 -1
  27. package/dist/core/Super.d.ts +7 -0
  28. package/dist/core/Super.js +15 -1
  29. package/dist/core/ViewsAdapter.d.ts +7 -2
  30. package/dist/core/ViewsAdapter.js +10 -3
  31. package/dist/index.d.ts +1 -0
  32. package/dist/index.js +1 -0
  33. package/dist/react/index.d.ts +4 -1
  34. package/dist/react/index.js +63 -9
  35. package/dist/react/reconciler.js +2 -1
  36. package/dist/vite/index.d.ts +9 -0
  37. package/dist/vite/index.js +36 -3
  38. package/package.json +3 -1
@@ -3,6 +3,7 @@ import { type Color, type LayoutOptions, SKRect, ScaledSize, type SkiaCacheType,
3
3
  import { type AnimatorBase, type IOverlayEffect } from "./Animators";
4
4
  import { Easing } from "./Easing";
5
5
  import type { Canvas } from "./Canvas";
6
+ import type { InputKey } from "./KeyboardManager";
6
7
  import { ContextMenuEventArgs, ControlTappedEventArgs, GestureEventProcessingInfo, type LockTouch, SKPoint, SkiaGesturesInfo, SkiaGesturesParameters } from "./Gestures";
7
8
  import { type CachedTexture, type IPostRendererEffect, type SkiaEffect } from "./SkiaEffect";
8
9
  /** Mirrors DrawnUi DrawingContext: ctx.Context.Canvas / Surface, ctx.Destination (pixels), ctx.Scale. */
@@ -251,7 +252,11 @@ export declare class SkiaControl {
251
252
  protected DefaultAccessibilityLabel(): string | undefined;
252
253
  get AccessibilityHint(): string | undefined;
253
254
  set AccessibilityHint(v: string | undefined);
254
- /** Tab stop + activation; unset = true when the control handles Tapped. */
255
+ /**
256
+ * Whether keyboard navigation and assistive technology may focus and use this node: the explicit value, else the class
257
+ * default (true when the control handles Tapped), and in both cases only while a tap could reach the control
258
+ * (CanReceiveGesture), so Tab, Enter / Space and screen-reader activation never use a control the pointer cannot.
259
+ */
255
260
  get AccessibilityCanInteract(): boolean;
256
261
  set AccessibilityCanInteract(v: boolean);
257
262
  protected DefaultAccessibilityCanInteract(): boolean;
@@ -288,6 +293,21 @@ export declare class SkiaControl {
288
293
  OnAccessibilityActivated(): void;
289
294
  /** Overlay focus arrives on / leaves this node (inputs may activate their sink here). */
290
295
  OnAccessibilityFocused(_focused: boolean): void;
296
+ /**
297
+ * Keyboard navigation hands this control the keys it may use while it holds keyboard focus (ArrowLeft / Right / Up /
298
+ * Down, Home, End, PageUp, PageDown, DOM `event.code` names). Return true when the key was used; the default uses none,
299
+ * so the key moves focus inside the arrow-key group around the control.
300
+ */
301
+ OnAccessibilityKey(_key: InputKey): boolean;
302
+ /**
303
+ * Whether a pointer gesture of this kind would reach this control, by the rules the gesture dispatch applies: the
304
+ * control and every ancestor draw, none of them is InputTransparent, no ancestor keeps this gesture from its children
305
+ * (LockChildrenGestures, PassNone included) and the control accepts input (AcceptsInput). Opacity does not count, as
306
+ * for the pointer. Keyboard navigation uses it: Tapped for Tab and Enter / Space, Panning for the arrow keys.
307
+ */
308
+ CanReceiveGesture(gesture: SkiaGesturesParameters["Type"]): boolean;
309
+ /** False while the control ignores gestures itself, e.g. a disabled button. Default true. */
310
+ protected AcceptsInput(): boolean;
291
311
  NotifyAccessibilityFocused(focused: boolean): void;
292
312
  /** DrawnUi SmartMax: larger of two sizes, an infinite one loses. */
293
313
  protected static SmartMax(a: number, b: number): number;
@@ -417,8 +417,12 @@ export class SkiaControl {
417
417
  this.accessibilityHint = v;
418
418
  this.AccessibilityChanged();
419
419
  } }
420
- /** Tab stop + activation; unset = true when the control handles Tapped. */
421
- get AccessibilityCanInteract() { return this.accessibilityCanInteract ?? this.DefaultAccessibilityCanInteract(); }
420
+ /**
421
+ * Whether keyboard navigation and assistive technology may focus and use this node: the explicit value, else the class
422
+ * default (true when the control handles Tapped), and in both cases only while a tap could reach the control
423
+ * (CanReceiveGesture), so Tab, Enter / Space and screen-reader activation never use a control the pointer cannot.
424
+ */
425
+ get AccessibilityCanInteract() { return (this.accessibilityCanInteract ?? this.DefaultAccessibilityCanInteract()) && this.CanReceiveGesture("Tapped"); }
422
426
  set AccessibilityCanInteract(v) { if (this.accessibilityCanInteract !== v) {
423
427
  this.accessibilityCanInteract = v;
424
428
  this.AccessibilityChanged();
@@ -503,6 +507,28 @@ export class SkiaControl {
503
507
  }
504
508
  /** Overlay focus arrives on / leaves this node (inputs may activate their sink here). */
505
509
  OnAccessibilityFocused(_focused) { }
510
+ /**
511
+ * Keyboard navigation hands this control the keys it may use while it holds keyboard focus (ArrowLeft / Right / Up /
512
+ * Down, Home, End, PageUp, PageDown, DOM `event.code` names). Return true when the key was used; the default uses none,
513
+ * so the key moves focus inside the arrow-key group around the control.
514
+ */
515
+ OnAccessibilityKey(_key) { return false; }
516
+ /**
517
+ * Whether a pointer gesture of this kind would reach this control, by the rules the gesture dispatch applies: the
518
+ * control and every ancestor draw, none of them is InputTransparent, no ancestor keeps this gesture from its children
519
+ * (LockChildrenGestures, PassNone included) and the control accepts input (AcceptsInput). Opacity does not count, as
520
+ * for the pointer. Keyboard navigation uses it: Tapped for Tab and Enter / Space, Panning for the arrow keys.
521
+ */
522
+ CanReceiveGesture(gesture) {
523
+ if (!this.IsVisible || this.IsDisposed || this.InputTransparent || !this.AcceptsInput())
524
+ return false;
525
+ for (let p = this.Parent; p; p = p.Parent)
526
+ if (!p.IsVisible || p.IsDisposed || p.InputTransparent || p.CheckChildrenGesturesLocked(gesture))
527
+ return false;
528
+ return true;
529
+ }
530
+ /** False while the control ignores gestures itself, e.g. a disabled button. Default true. */
531
+ AcceptsInput() { return true; }
506
532
  NotifyAccessibilityFocused(focused) { this.Superview?.AccessibilityManager.NotifyFocused(focused ? this : undefined); }
507
533
  /** DrawnUi SmartMax: larger of two sizes, an infinite one loses. */
508
534
  static SmartMax(a, b) {
@@ -228,7 +228,7 @@ export class SkiaShaderEffect extends SkiaEffect {
228
228
  static async FetchText(url) {
229
229
  let p = sourceCache.get(url);
230
230
  if (!p) {
231
- p = fetch(url).then((r) => { if (!r.ok)
231
+ p = fetch(Super.ResolveAssetUrl(url)).then((r) => { if (!r.ok)
232
232
  throw new Error(`${r.status} ${url}`); return r.text(); });
233
233
  sourceCache.set(url, p);
234
234
  }
@@ -39,7 +39,7 @@ export class SkiaImageManager {
39
39
  return inflight;
40
40
  }
41
41
  async Fetch(source, signal) {
42
- const response = await fetch(source, signal ? { signal } : undefined);
42
+ const response = await fetch(Super.ResolveAssetUrl(source), signal ? { signal } : undefined);
43
43
  if (!response.ok)
44
44
  throw new Error(`DrawnUi: ${response.status} loading image '${source}'`);
45
45
  const image = Super.CK.MakeImageFromEncoded(await response.arrayBuffer());
@@ -52,6 +52,13 @@ export declare class Super {
52
52
  static UseDrawnUi(): DrawnUiBuilder;
53
53
  /** Master switch: false makes every control render uncached (DrawnUi Super.CacheEnabled). */
54
54
  static CacheEnabled: boolean;
55
+ /**
56
+ * Address every engine loader fetches a source from (images, sprites, fonts, SVG, GIF, Lottie, shaders); caches
57
+ * stay keyed by the source itself. Web-only, no C# counterpart. Default: a file listed in
58
+ * `globalThis.DrawnUiAssetStamps` (written into index.html by the `drawnUiAssetStamps()` Vite plugin) gets
59
+ * `?v=<content hash>`, so a browser never keeps an old copy of a changed file. Replace it to map sources elsewhere.
60
+ */
61
+ static ResolveAssetUrl: (source: string) => string;
55
62
  private static readonly colorCache;
56
63
  /**
57
64
  * Color string -> CanvasKit color. Hex follows the MAUI/DrawnUi convention: #RGB, #ARGB, #RRGGBB, #AARRGGBB
@@ -46,7 +46,7 @@ export class DrawnUiBuilder {
46
46
  for (const f of this.fonts.Fonts) {
47
47
  let face = loaded.get(f.Source);
48
48
  if (!face) {
49
- const data = await (await fetch(f.Source)).arrayBuffer();
49
+ const data = await (await fetch(Super.ResolveAssetUrl(f.Source))).arrayBuffer();
50
50
  face = Super.CK.Typeface.MakeFreeTypeFaceFromData(data) ?? undefined;
51
51
  if (!face)
52
52
  throw new Error(`DrawnUi: cannot load font '${f.Source}'`);
@@ -122,6 +122,20 @@ export class Super {
122
122
  static UseDrawnUi() { return new DrawnUiBuilder(); }
123
123
  /** Master switch: false makes every control render uncached (DrawnUi Super.CacheEnabled). */
124
124
  static CacheEnabled = true;
125
+ /**
126
+ * Address every engine loader fetches a source from (images, sprites, fonts, SVG, GIF, Lottie, shaders); caches
127
+ * stay keyed by the source itself. Web-only, no C# counterpart. Default: a file listed in
128
+ * `globalThis.DrawnUiAssetStamps` (written into index.html by the `drawnUiAssetStamps()` Vite plugin) gets
129
+ * `?v=<content hash>`, so a browser never keeps an old copy of a changed file. Replace it to map sources elsewhere.
130
+ */
131
+ static ResolveAssetUrl = (source) => {
132
+ const stamps = globalThis.DrawnUiAssetStamps;
133
+ if (!stamps)
134
+ return source;
135
+ const q = source.indexOf("?");
136
+ const v = stamps[(q < 0 ? source : source.slice(0, q)).replace(/^\.?\//, "")];
137
+ return v ? `${source}${q < 0 ? "?" : "&"}v=${v}` : source;
138
+ };
125
139
  static colorCache = new Map();
126
140
  /**
127
141
  * Color string -> CanvasKit color. Hex follows the MAUI/DrawnUi convention: #RGB, #ARGB, #RRGGBB, #AARRGGBB
@@ -16,6 +16,8 @@ export declare class ViewsAdapter {
16
16
  Created: number;
17
17
  constructor(parent: SkiaControl);
18
18
  get PoolSize(): number;
19
+ /** The items views are bound to. */
20
+ get Items(): readonly unknown[];
19
21
  get InUseCount(): number;
20
22
  /** New template / items / mode: every realized view is dropped (DrawnUi ApplyItemsSource). */
21
23
  Initialize(template: (() => SkiaControl) | undefined, items: readonly unknown[], recycling: RecyclingTemplate): void;
@@ -26,8 +28,11 @@ export declare class ViewsAdapter {
26
28
  ShiftIndices(by: number, items: readonly unknown[]): void;
27
29
  /** The realized view for an index, if any (no creation). */
28
30
  GetViewForIndex(index: number): SkiaControl | undefined;
29
- /** View bound to items[index]: existing, recycled from the pool, or freshly created. */
30
- GetOrCreateViewForIndex(index: number): SkiaControl | undefined;
31
+ /**
32
+ * View bound to items[index]: existing, recycled from the pool, or freshly created. `quiet`: bound before it is
33
+ * attached, so the rebind invalidates the view alone and not the layout, which measures or draws it right away.
34
+ */
35
+ GetOrCreateViewForIndex(index: number, quiet?: boolean): SkiaControl | undefined;
31
36
  /** Index left the visible range: recycled views go back to the pool, non-recycled ones stay realized. */
32
37
  ReleaseViewAt(index: number): void;
33
38
  /** Releases every realized index outside [first, last]. */
@@ -16,6 +16,8 @@ export class ViewsAdapter {
16
16
  this.parent = parent;
17
17
  }
18
18
  get PoolSize() { return this.pool.length; }
19
+ /** The items views are bound to. */
20
+ get Items() { return this.items; }
19
21
  get InUseCount() { return this.inUse.size; }
20
22
  /** New template / items / mode: every realized view is dropped (DrawnUi ApplyItemsSource). */
21
23
  Initialize(template, items, recycling) {
@@ -52,8 +54,11 @@ export class ViewsAdapter {
52
54
  }
53
55
  /** The realized view for an index, if any (no creation). */
54
56
  GetViewForIndex(index) { return this.inUse.get(index); }
55
- /** View bound to items[index]: existing, recycled from the pool, or freshly created. */
56
- GetOrCreateViewForIndex(index) {
57
+ /**
58
+ * View bound to items[index]: existing, recycled from the pool, or freshly created. `quiet`: bound before it is
59
+ * attached, so the rebind invalidates the view alone and not the layout, which measures or draws it right away.
60
+ */
61
+ GetOrCreateViewForIndex(index, quiet = false) {
57
62
  const existing = this.inUse.get(index);
58
63
  if (existing)
59
64
  return existing;
@@ -64,9 +69,11 @@ export class ViewsAdapter {
64
69
  view = this.template();
65
70
  this.Created++;
66
71
  }
67
- view.Parent = this.parent;
72
+ if (!quiet)
73
+ view.Parent = this.parent;
68
74
  view.ContextIndex = index;
69
75
  view.BindingContext = this.items[index];
76
+ view.Parent = this.parent;
70
77
  this.inUse.set(index, view);
71
78
  return view;
72
79
  }
package/dist/index.d.ts CHANGED
@@ -12,6 +12,7 @@ export * from "./core/Accessibility";
12
12
  export * from "./controls/SkiaLabel";
13
13
  export * from "./controls/TextSpan";
14
14
  export * from "./controls/SkiaRichLabel";
15
+ export * from "./controls/SkiaLabelFps";
15
16
  export * from "./core/ControlStyle";
16
17
  export * from "./core/ImageEffects";
17
18
  export * from "./controls/SkiaToggle";
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ export * from "./core/Accessibility";
12
12
  export * from "./controls/SkiaLabel";
13
13
  export * from "./controls/TextSpan";
14
14
  export * from "./controls/SkiaRichLabel";
15
+ export * from "./controls/SkiaLabelFps";
15
16
  export * from "./core/ControlStyle";
16
17
  export * from "./core/ImageEffects";
17
18
  export * from "./controls/SkiaToggle";
@@ -4,6 +4,7 @@ import type { SkiaControl } from "../core/SkiaControl";
4
4
  import type { SkiaLabel as SkiaLabelCtrl } from "../controls/SkiaLabel";
5
5
  import type { TextSpan as TextSpanCtrl } from "../controls/TextSpan";
6
6
  import type { SkiaRichLabel as SkiaRichLabelCtrl } from "../controls/SkiaRichLabel";
7
+ import type { SkiaLabelFps as SkiaLabelFpsCtrl } from "../controls/SkiaLabelFps";
7
8
  import type { SkiaSwitch as SkiaSwitchCtrl } from "../controls/SkiaSwitch";
8
9
  import type { SkiaCheckbox as SkiaCheckboxCtrl } from "../controls/SkiaCheckbox";
9
10
  import type { SkiaRadioButton as SkiaRadioButtonCtrl } from "../controls/SkiaRadioButton";
@@ -22,7 +23,7 @@ import type { SkiaScrollBar as SkiaScrollBarCtrl } from "../controls/SkiaScrollB
22
23
  import type { RefreshIndicator as RefreshIndicatorCtrl } from "../controls/RefreshIndicator";
23
24
  import type { SkiaSvg as SkiaSvgCtrl } from "../controls/SkiaSvg";
24
25
  import type { SkiaBackdrop as SkiaBackdropCtrl } from "../controls/SkiaBackdrop";
25
- import type { SkiaEditor as SkiaEditorCtrl } from "../controls/SkiaEditor";
26
+ import { SkiaEditor as SkiaEditorCtrl } from "../controls/SkiaEditor";
26
27
  import type { SkiaSprite as SkiaSpriteCtrl } from "../controls/SkiaSprite";
27
28
  import type { SkiaSpriteSet as SkiaSpriteSetCtrl } from "../controls/SkiaSpriteSet";
28
29
  import type { SkiaLottie as SkiaLottieCtrl } from "../controls/SkiaLottie";
@@ -61,6 +62,8 @@ export declare const SkiaGrid: FC<LayoutProps<SkiaLayoutCtrl>>;
61
62
  /** Grid drawing gradient separator lines in its spacing (C# SkiaDecoratedGrid). */
62
63
  export declare const SkiaDecoratedGrid: FC<LayoutProps<SkiaDecoratedGridCtrl>>;
63
64
  export declare const SkiaLabel: FC<LayoutProps<SkiaLabelCtrl>>;
65
+ /** Canvas FPS counter (C# SkiaLabelFps); its value changes only on frames running animators request anyway. */
66
+ export declare const SkiaLabelFps: FC<LeafProps<SkiaLabelFpsCtrl>>;
64
67
  /** Markdown label (C# SkiaRichLabel): Text is markdown, rendered as spans; LinkTapped for [text](url). */
65
68
  export declare const SkiaRichLabel: FC<LeafProps<SkiaRichLabelCtrl>>;
66
69
  /** Child of <SkiaLabel>: a styled fragment (C# TextSpan). */
@@ -1,6 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
3
+ import { SkiaAccessibilityManager } from "../core/Accessibility";
4
+ import { TextInputProxy } from "../core/TextInputProxy";
3
5
  import { Canvas as CanvasView } from "../core/Canvas";
6
+ import { SkiaEditor as SkiaEditorCtrl } from "../controls/SkiaEditor";
4
7
  import { SkiaScroll as SkiaScrollCtrl } from "../controls/SkiaScroll";
5
8
  import { createDrawnRoot } from "./reconciler";
6
9
  /** Typed JSX tags resolved by the reconciler Registry. */
@@ -13,6 +16,8 @@ export const SkiaGrid = "SkiaGrid";
13
16
  /** Grid drawing gradient separator lines in its spacing (C# SkiaDecoratedGrid). */
14
17
  export const SkiaDecoratedGrid = "SkiaDecoratedGrid";
15
18
  export const SkiaLabel = "SkiaLabel";
19
+ /** Canvas FPS counter (C# SkiaLabelFps); its value changes only on frames running animators request anyway. */
20
+ export const SkiaLabelFps = "SkiaLabelFps";
16
21
  /** Markdown label (C# SkiaRichLabel): Text is markdown, rendered as spans; LinkTapped for [text](url). */
17
22
  export const SkiaRichLabel = "SkiaRichLabel";
18
23
  /** Child of <SkiaLabel>: a styled fragment (C# TextSpan). */
@@ -114,19 +119,50 @@ const A11Y_CSS = `
114
119
  .drawnui-a11y-text>span{position:absolute;white-space:pre;color:transparent;user-select:text;-webkit-user-select:text;pointer-events:auto;cursor:text;line-height:1;font-kerning:none;font-feature-settings:"kern" 0,"liga" 0,"calt" 0;text-rendering:geometricPrecision}
115
120
  .drawnui-a11y-text>span::selection{background:rgba(110,168,254,.55);color:transparent}
116
121
  `;
122
+ const NAVIGATION_KEYS = new Set(["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown"]);
117
123
  /**
118
- * Invisible ARIA elements mirroring the accessible drawn controls, positioned over the canvas.
119
- * Improvement over DrawnUi.Blazor: `pointer-events:none`, so hover and every pointer gesture still reach the canvas —
120
- * keyboard (Tab / Enter / Space) and screen-reader activation arrive as DOM events and are routed back as a Tapped.
124
+ * Invisible ARIA elements mirroring the accessible drawn controls, positioned over the canvas (the DrawnUi.Blazor
125
+ * overlay). Improvement over DrawnUi.Blazor: `pointer-events:none`, so hover and every pointer gesture still reach the
126
+ * canvas. Keyboard and screen-reader input arrive as DOM events: Enter / Space activate (a Tapped), the navigation keys
127
+ * go to the focused control and then to its arrow-key group; a group is one Tab stop (roving tabindex).
121
128
  */
122
129
  function AccessibilityOverlay({ view }) {
123
130
  const [nodes, setNodes] = useState(() => view.AccessibilityManager.Snapshot);
131
+ // keyboard focus moved by the manager (arrow keys in a group): focus that node's element once it is rendered
132
+ const [focusRequest, setFocusRequest] = useState();
133
+ // the Tab stop of a group moves with keyboard focus: re-render on focus
134
+ const [, setFocusTick] = useState(0);
124
135
  useEffect(() => {
125
136
  const mgr = view.AccessibilityManager;
126
137
  setNodes(mgr.Snapshot);
127
138
  const off = mgr.OnChanged(() => setNodes(mgr.Snapshot));
128
139
  const offLive = mgr.OnLiveRegionUpdated(() => { mgr.ForceRebuildOnNextFrame(); view.Update(); });
129
- return () => { off(); offLive(); };
140
+ const offFocus = mgr.OnKeyboardFocusRequested((node) => { setNodes(mgr.Snapshot); setFocusRequest({ id: node.AccessibilityId }); });
141
+ return () => { off(); offLive(); offFocus(); };
142
+ }, [view]);
143
+ const elementOf = (id) => overlayRef.current?.querySelector(`[data-a11y-id="${id}"]`) ?? undefined;
144
+ useLayoutEffect(() => {
145
+ if (!focusRequest)
146
+ return;
147
+ setFocusRequest(undefined);
148
+ elementOf(focusRequest.id)?.focus({ preventScroll: true });
149
+ }, [focusRequest]);
150
+ // Tab / Shift+Tab in a text field leave it for the next node, also after a click into it: DOM focus goes to the
151
+ // field's own element (without taking the caret again) and the browser's Tab moves on from there
152
+ const tabbingOut = useRef(0);
153
+ useEffect(() => {
154
+ const onKey = (e) => {
155
+ const editor = SkiaEditorCtrl.Focused;
156
+ if (e.key !== "Tab" || !editor || editor.Superview !== view || !TextInputProxy.IsProxyTarget(e.target))
157
+ return;
158
+ const el = elementOf(editor.AccessibilityId);
159
+ if (!el)
160
+ return;
161
+ tabbingOut.current = editor.AccessibilityId;
162
+ el.focus({ preventScroll: true });
163
+ };
164
+ document.addEventListener("keydown", onKey, true);
165
+ return () => document.removeEventListener("keydown", onKey, true);
130
166
  }, [view]);
131
167
  // selectable text spans take pointer events: the wheel over them is re-dispatched to the canvas so drawn scrolls keep working
132
168
  const overlayRef = useRef(null);
@@ -161,11 +197,29 @@ function AccessibilityOverlay({ view }) {
161
197
  const pin = (e) => { e.currentTarget.scrollTop = 0; e.currentTarget.scrollLeft = 0; };
162
198
  return (_jsxs("div", { className: "drawnui-a11y-overlay", onScroll: pin, ref: overlayRef, children: [_jsx("style", { children: A11Y_CSS }), nodes.map((n) => {
163
199
  const pos = { left: n.Rect.Left, top: n.Rect.Top, width: n.Rect.Width, height: n.Rect.Height };
164
- const activate = () => n.Source.OnAccessibilityActivated();
165
- return n.CanInteract ? (_jsx("div", { role: n.Role, "aria-label": n.Label, title: n.Hint, "aria-pressed": n.Role === "button" ? n.IsPressed : undefined, "aria-checked": n.Role === "switch" || n.Role === "checkbox" || n.Role === "radio" ? n.IsPressed : undefined, "aria-live": n.Live, tabIndex: 0, className: "drawnui-a11y-node", style: { ...pos, fontSize: 0, userSelect: "none" }, onClick: activate, onKeyDown: (e) => { if (e.key === "Enter" || e.key === " ") {
166
- e.preventDefault();
167
- activate();
168
- } }, onFocus: (e) => { pin({ currentTarget: e.currentTarget.parentElement }); SkiaScrollCtrl.EnsureVisible(n.Source); n.Source.OnAccessibilityFocused(true); n.Source.NotifyAccessibilityFocused(true); }, onBlur: () => { n.Source.OnAccessibilityFocused(false); n.Source.NotifyAccessibilityFocused(false); }, children: n.Label }, n.Id)) : n.TextLines ? (
200
+ const activate = () => SkiaAccessibilityManager.Activate(n.Source);
201
+ return n.CanInteract ? (_jsx("div", { "data-a11y-id": n.Id, role: n.Role, "aria-label": n.Label, title: n.Hint, "aria-pressed": n.Role === "button" ? n.IsPressed : undefined, "aria-checked": n.Role === "switch" || n.Role === "checkbox" || n.Role === "radio" ? n.IsPressed : undefined, "aria-live": n.Live, tabIndex: view.AccessibilityManager.IsTabStop(n.Source) ? 0 : -1, className: "drawnui-a11y-node", style: { ...pos, fontSize: 0, userSelect: "none" }, onClick: activate, onKeyDown: (e) => {
202
+ if (e.key === "Enter" || e.key === " ") {
203
+ e.preventDefault();
204
+ activate();
205
+ }
206
+ else if (NAVIGATION_KEYS.has(e.key) && SkiaAccessibilityManager.Key(n.Source, e.key))
207
+ e.preventDefault();
208
+ }, onFocus: (e) => {
209
+ pin({ currentTarget: e.currentTarget.parentElement });
210
+ SkiaScrollCtrl.EnsureVisible(n.Source); // inside every enclosing scroll
211
+ if (tabbingOut.current === n.Id)
212
+ tabbingOut.current = 0; // leaving a text field: it keeps no caret
213
+ else
214
+ n.Source.OnAccessibilityFocused(true); // a text field takes the caret, as after a click
215
+ n.Source.NotifyAccessibilityFocused(true);
216
+ setFocusTick((t) => t + 1);
217
+ }, onBlur: (e) => {
218
+ if (TextInputProxy.IsProxyTarget(e.relatedTarget))
219
+ return; // a text field moved DOM focus to its own input
220
+ n.Source.OnAccessibilityFocused(false);
221
+ n.Source.NotifyAccessibilityFocused(false);
222
+ }, children: n.Label }, n.Id)) : n.TextLines ? (
169
223
  // AccessibilityTextSelectable: one positioned span per drawn line, in the drawn font, so the browser selects and copies it like HTML
170
224
  _jsx("div", { role: n.Role, title: n.Hint, "aria-live": n.Live, className: "drawnui-a11y-node drawnui-a11y-text", style: pos, children: n.TextLines.map((l, i) => (_jsxs("span", { "data-w": l.Width, style: { left: l.Left, top: l.Top, height: l.Height, fontFamily: l.FontFamily, fontWeight: l.FontWeight, fontSize: l.FontSize, lineHeight: `${l.Height}px` }, children: [l.Text, i < n.TextLines.length - 1 ? "\n" : ""] }, i))) }, n.Id)) : (
171
225
  // static text is exposed as real (transparent) text content; aria-label only for roles that need a name
@@ -4,6 +4,7 @@ import { ConcurrentRoot, DefaultEventPriority } from "react-reconciler/constants
4
4
  import { SkiaLabel } from "../controls/SkiaLabel";
5
5
  import { TextSpan } from "../controls/TextSpan";
6
6
  import { SkiaRichLabel } from "../controls/SkiaRichLabel";
7
+ import { SkiaLabelFps } from "../controls/SkiaLabelFps";
7
8
  import { SkiaSwitch } from "../controls/SkiaSwitch";
8
9
  import { SkiaCheckbox } from "../controls/SkiaCheckbox";
9
10
  import { SkiaRadioButton } from "../controls/SkiaRadioButton";
@@ -31,7 +32,7 @@ import { SkiaScroll } from "../controls/SkiaScroll";
31
32
  import { SkiaFrame, SkiaShape } from "../controls/SkiaShape";
32
33
  /** JSX tag name -> engine class. Add a control here to expose it to React. */
33
34
  export const Registry = {
34
- SkiaLayout, SkiaStack, SkiaRow, SkiaLayer, SkiaWrap, SkiaGrid, SkiaDecoratedGrid, SkiaBackdrop, SkiaEditor, SkiaSprite, SkiaSpriteSet, SkiaLabel, SkiaRichLabel, TextSpan, SkiaHotspot, SkiaButton, SkiaImage, SkiaImageTiles, SkiaSvg, SkiaLottie, SkiaGif, SkiaScroll, SkiaScrollBar, RefreshIndicator, SkiaShape, SkiaFrame,
35
+ SkiaLayout, SkiaStack, SkiaRow, SkiaLayer, SkiaWrap, SkiaGrid, SkiaDecoratedGrid, SkiaBackdrop, SkiaEditor, SkiaSprite, SkiaSpriteSet, SkiaLabel, SkiaLabelFps, SkiaRichLabel, TextSpan, SkiaHotspot, SkiaButton, SkiaImage, SkiaImageTiles, SkiaSvg, SkiaLottie, SkiaGif, SkiaScroll, SkiaScrollBar, RefreshIndicator, SkiaShape, SkiaFrame,
35
36
  SkiaSwitch, SkiaCheckbox, SkiaRadioButton, SkiaProgress, SkiaSlider, SkiaCarousel, SkiaShaderCarousel, SkiaDrawer,
36
37
  };
37
38
  const SKIP = new Set(["children", "key", "ref"]);
@@ -1,4 +1,13 @@
1
1
  import type { Plugin } from "vite";
2
+ /**
3
+ * Versioned addresses for the files of the public folder (fonts, images, sprites, shaders, Lottie, favicon). Their
4
+ * names never change, and a browser or CDN may keep them for hours, so after a publish a returning visitor could
5
+ * get new code with an old file. On build every public file gets a short content hash: index.html receives the map
6
+ * as `globalThis.DrawnUiAssetStamps` (read by `Super.ResolveAssetUrl`, which every engine loader fetches through)
7
+ * and its own `href` / `src` links to public files get `?v=<hash>`. A changed file gets a new address, an unchanged
8
+ * one keeps its address and stays cached. Bundled files (hashed names) need nothing. The dev server is left alone.
9
+ */
10
+ export declare function drawnUiAssetStamps(): Plugin;
2
11
  export interface DrawnUiStaticOptions {
3
12
  /**
4
13
  * Pages to generate, as URL hashes / paths appended to the site root ("" = the root page, "#/shapes"...).
@@ -1,9 +1,42 @@
1
1
  /**
2
- * Vite helpers for DrawnUi.React apps: `import { drawnUiStatic } from "drawnui-react/vite"`.
2
+ * Vite helpers for DrawnUi.React apps: `import { drawnUiStatic, drawnUiAssetStamps } from "drawnui-react/vite"`.
3
3
  * Build-time only; the runtime bundle never imports this module and the frame loop knows nothing about it.
4
4
  */
5
- import { readFileSync, writeFileSync } from "node:fs";
6
- import { resolve } from "node:path";
5
+ import { createHash } from "node:crypto";
6
+ import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
7
+ import { join, resolve } from "node:path";
8
+ /**
9
+ * Versioned addresses for the files of the public folder (fonts, images, sprites, shaders, Lottie, favicon). Their
10
+ * names never change, and a browser or CDN may keep them for hours, so after a publish a returning visitor could
11
+ * get new code with an old file. On build every public file gets a short content hash: index.html receives the map
12
+ * as `globalThis.DrawnUiAssetStamps` (read by `Super.ResolveAssetUrl`, which every engine loader fetches through)
13
+ * and its own `href` / `src` links to public files get `?v=<hash>`. A changed file gets a new address, an unchanged
14
+ * one keeps its address and stays cached. Bundled files (hashed names) need nothing. The dev server is left alone.
15
+ */
16
+ export function drawnUiAssetStamps() {
17
+ let stamps = {};
18
+ return {
19
+ name: "drawnui-asset-stamps",
20
+ apply: "build",
21
+ configResolved(config) {
22
+ const dir = config.publicDir;
23
+ if (!dir)
24
+ return;
25
+ for (const rel of readdirSync(dir, { recursive: true })) {
26
+ const file = join(dir, rel);
27
+ if (statSync(file).isFile())
28
+ stamps[rel.replace(/\\/g, "/")] = createHash("sha256").update(readFileSync(file)).digest("hex").slice(0, 10);
29
+ }
30
+ },
31
+ transformIndexHtml(html) {
32
+ const stamped = html.replace(/\b(href|src)="([^"?#:]+)"/g, (m, attr, url) => {
33
+ const v = stamps[url.replace(/^\.?\//, "")];
34
+ return v ? `${attr}="${url}?v=${v}"` : m;
35
+ });
36
+ return { html: stamped, tags: [{ tag: "script", children: `globalThis.DrawnUiAssetStamps=${JSON.stringify(stamps)}`, injectTo: "head-prepend" }] };
37
+ },
38
+ };
39
+ }
7
40
  /**
8
41
  * Generates visible, semantic HTML for crawlers and AI agents from the app's own accessibility tree, at build time.
9
42
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drawnui-react",
3
- "version": "0.1.0-preview.11",
3
+ "version": "0.1.0-preview.13",
4
4
  "description": "DrawnUi for React: the DrawnUi (.NET) drawn-UI engine in TypeScript on CanvasKit (Skia WASM). Same controls, same PascalCase API as DrawnUi.Net.",
5
5
  "license": "MIT",
6
6
  "author": "Nick Kovalsky (AppoMobi)",
@@ -53,6 +53,8 @@
53
53
  "typecheck": "tsc --noEmit",
54
54
  "build": "tsc --noEmit && node dev/build-samples.mjs",
55
55
  "sync:skills": "node dev/sync-skills.mjs",
56
+ "check:recycling": "node dev/check.mjs dev/check-recycling.ts",
57
+ "check:keyboard": "node dev/check.mjs dev/check-keyboard.ts",
56
58
  "site:extras": "node dev/site-extras.mjs dist/demo",
57
59
  "build:lib": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
58
60
  "prepublishOnly": "npm run build:lib",