drawnui-react 0.1.0-preview.16 → 0.1.0-preview.17

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 (50) hide show
  1. package/README.md +1 -1
  2. package/SKIPPED.md +5 -5
  3. package/dist/controls/AnimatedFramesRenderer.d.ts +7 -0
  4. package/dist/controls/AnimatedFramesRenderer.js +16 -1
  5. package/dist/controls/DrawnGame.d.ts +4 -1
  6. package/dist/controls/DrawnGame.js +7 -3
  7. package/dist/controls/GridStructure.d.ts +13 -1
  8. package/dist/controls/GridStructure.js +38 -2
  9. package/dist/controls/SkiaCarousel.d.ts +1 -1
  10. package/dist/controls/SkiaCarousel.js +3 -2
  11. package/dist/controls/SkiaEditor.d.ts +5 -1
  12. package/dist/controls/SkiaEditor.js +19 -17
  13. package/dist/controls/SkiaGif.d.ts +3 -0
  14. package/dist/controls/SkiaGif.js +11 -1
  15. package/dist/controls/SkiaImage.js +15 -10
  16. package/dist/controls/SkiaLabel.d.ts +52 -3
  17. package/dist/controls/SkiaLabel.js +222 -50
  18. package/dist/controls/SkiaLayout.d.ts +5 -0
  19. package/dist/controls/SkiaLayout.js +7 -4
  20. package/dist/controls/SkiaLottie.js +3 -1
  21. package/dist/controls/SkiaProgress.d.ts +2 -1
  22. package/dist/controls/SkiaProgress.js +4 -1
  23. package/dist/controls/SkiaScroll.d.ts +12 -0
  24. package/dist/controls/SkiaScroll.js +76 -8
  25. package/dist/controls/SkiaShape.d.ts +13 -2
  26. package/dist/controls/SkiaShape.js +50 -10
  27. package/dist/controls/SkiaSlider.d.ts +5 -1
  28. package/dist/controls/SkiaSlider.js +7 -1
  29. package/dist/controls/SkiaSprite.d.ts +1 -0
  30. package/dist/controls/SkiaSprite.js +4 -0
  31. package/dist/controls/SnappingLayout.d.ts +5 -0
  32. package/dist/controls/SnappingLayout.js +8 -0
  33. package/dist/core/Accessibility.d.ts +13 -0
  34. package/dist/core/Accessibility.js +10 -4
  35. package/dist/core/Animators.d.ts +28 -1
  36. package/dist/core/Animators.js +76 -8
  37. package/dist/core/Canvas.d.ts +21 -4
  38. package/dist/core/Canvas.js +122 -23
  39. package/dist/core/Easing.d.ts +12 -1
  40. package/dist/core/Easing.js +25 -1
  41. package/dist/core/Gestures.d.ts +24 -1
  42. package/dist/core/Gestures.js +75 -2
  43. package/dist/core/Hover.d.ts +14 -4
  44. package/dist/core/Hover.js +38 -22
  45. package/dist/core/ScrollAnimators.js +9 -0
  46. package/dist/core/SkiaControl.d.ts +17 -5
  47. package/dist/core/SkiaControl.js +69 -37
  48. package/dist/react/SkiaShell.js +33 -14
  49. package/dist/react/index.js +29 -2
  50. package/package.json +15 -1
@@ -1,6 +1,7 @@
1
1
  // Mirrors DrawnUi scrolling physics: DecelerationTimingParameters + ScrollFlingAnimator (fling),
2
2
  // Spring + SpringTimingParameters + SpringWithVelocityAnimator (bounce), VelocityAccumulator, RubberBandUtils.
3
3
  import { SkiaValueAnimator } from "./Animators";
4
+ import { VelocityTrail } from "./Gestures";
4
5
  /** UIKit-style exponential deceleration: value(t) = v0 * (rate^(1000 t) - 1) / k, k = 1000 ln(rate). */
5
6
  export class DecelerationTimingParameters {
6
7
  InitialValue;
@@ -201,6 +202,14 @@ export class VelocityAccumulator {
201
202
  Clear() { this.samples.length = 0; }
202
203
  CaptureVelocity(x, y, arrivedTimeNanos = 0) {
203
204
  const time = arrivedTimeNanos > 0 ? arrivedTimeNanos / 1e6 : performance.now();
205
+ // a move in the same burst as the last one replaces it: the first move of a burst carries only part of the burst's
206
+ // distance (DrawnUi.Rust VelocityAccumulator, 6d525f7)
207
+ const last = this.samples[this.samples.length - 1];
208
+ if (last && time - last.time < VelocityTrail.BurstMs) {
209
+ last.X = x;
210
+ last.Y = y;
211
+ return;
212
+ }
204
213
  if (this.samples.length === VelocityAccumulator.MaxSampleSize)
205
214
  this.samples.shift();
206
215
  this.samples.push({ X: x, Y: y, time });
@@ -166,15 +166,15 @@ export declare class SkiaControl {
166
166
  private compositeDeep?;
167
167
  /** Set while an UpdateDraw climbs: the ancestors re-measure but an ImageComposite among them keeps its partial record. */
168
168
  private static drawOrigin;
169
- /** Nonzero while a composite redraws only some areas: layouts skip children entirely outside the clip. */
170
- static CompositionCulling: number;
171
169
  /** The children a composite record can re-record individually; layouts return their views. */
172
170
  protected GetCompositeChildren(): readonly SkiaControl[];
173
171
  /** C# TrackChildAsDirty (DirtyChildrenTracker): the child changed without a remeasure of this control. */
174
172
  TrackChildAsDirty(child: SkiaControl): void;
175
173
  private static readonly rejectRect;
176
- /** During a partial composite record: true when this control draws nothing inside the canvas clip (skipped whole). */
177
- OutsideCompositionClip(canvas: SkCanvas): boolean;
174
+ /** Its cache must be recorded before it can be drawn (none, dirty, another type, scale, size or GPU context). */
175
+ private CacheStale;
176
+ /** Its rect grown by its effects margin, through the matrix it is drawn with, lies outside the canvas clip. */
177
+ private OutsideClip;
178
178
  /** Drawn bounds in canvas pixels including transforms and effects margins (C# GetTransformedDirtyBounds). */
179
179
  GetTransformedDirtyBounds(): SKRect;
180
180
  /** Current cache, if any. */
@@ -214,7 +214,7 @@ export declare class SkiaControl {
214
214
  * top-most first, by the routing rules of ProcessGestures. True when this subtree blocks gestures below it, so the
215
215
  * controls under it get no hover (a popup over a list).
216
216
  */
217
- CollectHovered(point: SKPoint, into: SkiaControl[]): boolean;
217
+ CollectHovered(point: SKPoint, into: SkiaControl[], xs?: number[], ys?: number[]): boolean;
218
218
  TouchEffectColor: Color;
219
219
  /** Plays a ripple (from the tap point) when Tapped fires. */
220
220
  AnimationTapped: SkiaTouchAnimation;
@@ -332,6 +332,8 @@ export declare class SkiaControl {
332
332
  * host's pointer cursor. Default: the control as a whole (AccessibilityCanInteract); SkiaLabel adds its tappable spans.
333
333
  */
334
334
  WantsPointerCursor(_x: number, _y: number): boolean;
335
+ /** A range control's value for screen readers (slider, progress); undefined for everything else (drawnui-cross 6c). */
336
+ get AccessibilityValue(): import("./Accessibility").AccessibilityValue | undefined;
335
337
  /** aria-pressed for toggles; undefined = not a toggle. */
336
338
  get AccessibilityIsPressed(): boolean | undefined;
337
339
  set AccessibilityIsPressed(v: boolean | undefined);
@@ -471,6 +473,16 @@ export declare class SkiaControl {
471
473
  Update(): void;
472
474
  /** Redraw without remeasure and WITHOUT dropping caches (position/overlay changes). */
473
475
  Repaint(): void;
476
+ /**
477
+ * A parent (not the control itself) is hidden: a change here asks for no frame and the latest state shows when the
478
+ * parent is shown again (C# 329f6c44 HiddenUpdateTests, DrawnUi.Rust hidden_updates). The control's own visibility
479
+ * change still draws: it is the one that hides or shows it.
480
+ */
481
+ UnderHiddenParent(): boolean;
482
+ /** The control or a parent is hidden: its animators pause until it shows again. */
483
+ HiddenInTree(): boolean;
484
+ /** The control an invalidation started at while it climbs (InvalidateMeasure): a hidden parent of it wakes no frame. */
485
+ private static invalidateOrigin;
474
486
  /**
475
487
  * Transform / Opacity changed: this control's own cache is still valid (content unchanged) but every ancestor
476
488
  * cache holds its composited output, so those are staled before redrawing (DrawnUi RedrawCanvas + parent invalidation).
@@ -189,17 +189,24 @@ export class SkiaControl {
189
189
  compositeDeep;
190
190
  /** Set while an UpdateDraw climbs: the ancestors re-measure but an ImageComposite among them keeps its partial record. */
191
191
  static drawOrigin = null;
192
- /** Nonzero while a composite redraws only some areas: layouts skip children entirely outside the clip. */
193
- static CompositionCulling = 0;
194
192
  /** The children a composite record can re-record individually; layouts return their views. */
195
193
  GetCompositeChildren() { return []; }
196
194
  /** C# TrackChildAsDirty (DirtyChildrenTracker): the child changed without a remeasure of this control. */
197
195
  TrackChildAsDirty(child) { if (this.IsCacheComposite)
198
196
  this.DirtyChildrenInternal.add(child); }
199
197
  static rejectRect = new Float32Array(4);
200
- /** During a partial composite record: true when this control draws nothing inside the canvas clip (skipped whole). */
201
- OutsideCompositionClip(canvas) {
202
- const b = this.DrawnBoundsNow(this.RenderingScale), q = SkiaControl.rejectRect;
198
+ /** Its cache must be recorded before it can be drawn (none, dirty, another type, scale, size or GPU context). */
199
+ CacheStale(scale) {
200
+ const cacheType = this.UsingCacheType;
201
+ if (cacheType === "None")
202
+ return false;
203
+ const o = this.RenderObject, r = cacheType === "Operations" ? this.ExpandedCacheRect(scale) : this.AlignedCacheRect(scale);
204
+ return this.cacheDirty || !o || o.Type !== cacheType || o.Epoch !== (this.Superview?.GpuEpoch ?? 0) || o.Scale !== scale
205
+ || Math.round(o.Bounds.Width) !== Math.round(r.Width) || Math.round(o.Bounds.Height) !== Math.round(r.Height);
206
+ }
207
+ /** Its rect grown by its effects margin, through the matrix it is drawn with, lies outside the canvas clip. */
208
+ OutsideClip(canvas, scale) {
209
+ const b = SkiaControl.MapRect(this.RenderTransformMatrix, this.ExpandedCacheRect(scale)), q = SkiaControl.rejectRect;
203
210
  q[0] = b.Left;
204
211
  q[1] = b.Top;
205
212
  q[2] = b.Right;
@@ -259,15 +266,17 @@ export class SkiaControl {
259
266
  * top-most first, by the routing rules of ProcessGestures. True when this subtree blocks gestures below it, so the
260
267
  * controls under it get no hover (a popup over a list).
261
268
  */
262
- CollectHovered(point, into) {
269
+ CollectHovered(point, into, xs, ys) {
263
270
  into.push(this);
271
+ xs?.push(point.X);
272
+ ys?.push(point.Y);
264
273
  if (!this.CheckChildrenGesturesLocked("Pointer")) {
265
274
  const listeners = this.GetGestureListeners();
266
275
  for (let i = listeners.length - 1; i >= 0; i--) {
267
276
  const child = listeners[i];
268
277
  if (!child.IsVisible || child.InputTransparent || !this.IsGestureForChild(child, point))
269
278
  continue;
270
- if (child.CollectHovered(child.TransformPointToLocalSpace(point), into))
279
+ if (child.CollectHovered(child.TransformPointToLocalSpace(point), into, xs, ys))
271
280
  return true;
272
281
  }
273
282
  }
@@ -528,6 +537,8 @@ export class SkiaControl {
528
537
  * host's pointer cursor. Default: the control as a whole (AccessibilityCanInteract); SkiaLabel adds its tappable spans.
529
538
  */
530
539
  WantsPointerCursor(_x, _y) { return this.AccessibilityCanInteract; }
540
+ /** A range control's value for screen readers (slider, progress); undefined for everything else (drawnui-cross 6c). */
541
+ get AccessibilityValue() { return undefined; }
531
542
  /** aria-pressed for toggles; undefined = not a toggle. */
532
543
  get AccessibilityIsPressed() { return this.accessibilityIsPressed; }
533
544
  set AccessibilityIsPressed(v) { if (this.accessibilityIsPressed !== v) {
@@ -691,6 +702,23 @@ export class SkiaControl {
691
702
  const offsetOnly = (this.Left !== 0 || this.Top !== 0) && this.UsingCacheType !== "None" && this.TranslationX === 0 && this.TranslationY === 0
692
703
  && this.Rotation === 0 && this.ScaleX === 1 && this.ScaleY === 1 && this.SkewX === 0 && this.SkewY === 0;
693
704
  const needTransform = this.HasTransform && !offsetOnly;
705
+ let dx = 0, dy = 0;
706
+ if (needTransform) {
707
+ this.RenderTransformMatrix = this.CreateRenderTransformMatrix(this.DrawingRect, ctx.Scale);
708
+ }
709
+ else if (offsetOnly) {
710
+ dx = this.Left * ctx.Scale;
711
+ dy = this.Top * ctx.Scale;
712
+ this.RenderTransformMatrix = Super.CK.Matrix.translated(dx, dy); // gestures / accessibility still map through it
713
+ }
714
+ else {
715
+ this.RenderTransformMatrix = undefined;
716
+ }
717
+ // drawn entirely outside the clip: skipped with its subtree (C# layout Virtualisation, DrawnUi.Rust); the matrix
718
+ // above stays current for hit tests. A cache that must be recorded is still recorded here, as before, so its cost
719
+ // does not move into the frame where the control scrolls in (DrawnUi.Rust records it then)
720
+ if (this.OutsideClip(canvas, ctx.Scale) && !this.CacheStale(ctx.Scale))
721
+ return;
694
722
  let saved = false;
695
723
  // same as DrawnUi: opacity = a layer with alpha, transforms = canvas matrix around the whole subtree (cache included)
696
724
  if (applyOpacity) {
@@ -704,19 +732,8 @@ export class SkiaControl {
704
732
  canvas.save();
705
733
  saved = true;
706
734
  }
707
- let dx = 0, dy = 0;
708
- if (needTransform) {
709
- this.RenderTransformMatrix = this.CreateRenderTransformMatrix(this.DrawingRect, ctx.Scale);
735
+ if (needTransform)
710
736
  canvas.concat(this.RenderTransformMatrix);
711
- }
712
- else if (offsetOnly) {
713
- dx = this.Left * ctx.Scale;
714
- dy = this.Top * ctx.Scale;
715
- this.RenderTransformMatrix = Super.CK.Matrix.translated(dx, dy); // gestures / accessibility still map through it
716
- }
717
- else {
718
- this.RenderTransformMatrix = undefined;
719
- }
720
737
  if (this.IsClippedToBounds) {
721
738
  if (!saved) {
722
739
  canvas.save();
@@ -781,10 +798,7 @@ export class SkiaControl {
781
798
  const epoch = this.Superview?.GpuEpoch ?? 0;
782
799
  if (this.RenderObjectPrevious && this.RenderObjectPrevious.Epoch !== epoch)
783
800
  this.DisposePrevious(); // made on a lost GPU context
784
- const stale = this.cacheDirty || !this.RenderObject || this.RenderObject.Type !== cacheType || this.RenderObject.Epoch !== epoch
785
- || this.RenderObject.Scale !== ctx.Scale
786
- || Math.round(this.RenderObject.Bounds.Width) !== Math.round(r.Width) || Math.round(this.RenderObject.Bounds.Height) !== Math.round(r.Height);
787
- if (stale)
801
+ if (this.CacheStale(ctx.Scale))
788
802
  this.CreateRenderingObject({ ...ctx, Destination: this.DrawingRect }, cacheType);
789
803
  if (this.RenderObject) {
790
804
  // C# DrawRenderObject: with post renderers an Image cache is not blitted, the effects sample it (CachedImage)
@@ -982,13 +996,8 @@ export class SkiaControl {
982
996
  this.DirtyChildrenInternal.add(c);
983
997
  for (const c of inside)
984
998
  this.DirtyChildrenInternal.add(c);
985
- SkiaControl.CompositionCulling++;
986
- try {
987
- this.PaintContent({ ...ctx, Context: { Canvas: canvas, Surface: surface, Origin: { X: r.Left, Y: r.Top } } });
988
- }
989
- finally {
990
- SkiaControl.CompositionCulling--;
991
- }
999
+ // children entirely outside the redrawn areas are skipped by their own clip check (Render)
1000
+ this.PaintContent({ ...ctx, Context: { Canvas: canvas, Surface: surface, Origin: { X: r.Left, Y: r.Top } } });
992
1001
  this.IsRenderingWithComposition = false;
993
1002
  const redrawn = children.filter((c) => this.DirtyChildrenInternal.has(c));
994
1003
  this.LastCompositeRecord = { Mode: "partial", Children: redrawn.length, Dirty: redrawn, Areas: areas, Changed: changed };
@@ -1257,8 +1266,24 @@ export class SkiaControl {
1257
1266
  }
1258
1267
  /** Redraw without remeasure and WITHOUT dropping caches (position/overlay changes). */
1259
1268
  Repaint() {
1260
- this.Superview?.Update();
1269
+ if (!this.UnderHiddenParent())
1270
+ this.Superview?.Update();
1261
1271
  }
1272
+ /**
1273
+ * A parent (not the control itself) is hidden: a change here asks for no frame and the latest state shows when the
1274
+ * parent is shown again (C# 329f6c44 HiddenUpdateTests, DrawnUi.Rust hidden_updates). The control's own visibility
1275
+ * change still draws: it is the one that hides or shows it.
1276
+ */
1277
+ UnderHiddenParent() {
1278
+ for (let p = this.Parent; p; p = p.Parent)
1279
+ if (!p.IsVisible)
1280
+ return true;
1281
+ return false;
1282
+ }
1283
+ /** The control or a parent is hidden: its animators pause until it shows again. */
1284
+ HiddenInTree() { return !this.IsVisible || this.UnderHiddenParent(); }
1285
+ /** The control an invalidation started at while it climbs (InvalidateMeasure): a hidden parent of it wakes no frame. */
1286
+ static invalidateOrigin = null;
1262
1287
  /**
1263
1288
  * Transform / Opacity changed: this control's own cache is still valid (content unchanged) but every ancestor
1264
1289
  * cache holds its composited output, so those are staled before redrawing (DrawnUi RedrawCanvas + parent invalidation).
@@ -1356,12 +1381,19 @@ export class SkiaControl {
1356
1381
  if (SkiaControl.drawOrigin === null || SkiaControl.drawOrigin === this)
1357
1382
  this.compositeFull = true;
1358
1383
  this.effectsMarginCache = undefined;
1359
- if (this.Parent && this.IsParentIndependent)
1360
- this.RepaintComposition();
1361
- else if (this.Parent)
1362
- this.Parent.InvalidateMeasure();
1363
- else
1364
- this.Superview?.Update();
1384
+ const outer = SkiaControl.invalidateOrigin;
1385
+ SkiaControl.invalidateOrigin ??= this;
1386
+ try {
1387
+ if (this.Parent && this.IsParentIndependent)
1388
+ this.RepaintComposition();
1389
+ else if (this.Parent)
1390
+ this.Parent.InvalidateMeasure();
1391
+ else if (!SkiaControl.invalidateOrigin.UnderHiddenParent())
1392
+ this.Superview?.Update();
1393
+ }
1394
+ finally {
1395
+ SkiaControl.invalidateOrigin = outer;
1396
+ }
1365
1397
  }
1366
1398
  // ---- gestures (same names as DrawnUi) ----
1367
1399
  /** Hit rect in pixels (no transforms in this port, so it is DrawingRect). */
@@ -389,6 +389,12 @@ export const SkiaShell = forwardRef(function SkiaShell({ Routes, children, NavBa
389
389
  prev ? prev.TranslateToAsync(-dir * w, 0, TabsAnimationSpeed, TabsEasing) : Promise.resolve(),
390
390
  ]), TabsAnimationSpeed + 500);
391
391
  }
392
+ // the tab left stays mounted (its state kept): hidden first, then back in place for its next turn
393
+ if (prev) {
394
+ prev.IsVisible = false;
395
+ prev.TranslationX = 0;
396
+ prev.TranslationY = 0;
397
+ }
392
398
  setTabTransition(null);
393
399
  }, [hasTabs, Tabs, AnimateTabs, TabsAnimationSpeed]);
394
400
  // popstate: the browser popped OUR last entry (back) — or moved forward to a hash we can rebuild
@@ -405,7 +411,13 @@ export const SkiaShell = forwardRef(function SkiaShell({ Routes, children, NavBa
405
411
  }
406
412
  const onPop = () => {
407
413
  const depth = window.history.state?.[HISTORY_KEY] ?? 0;
408
- if (depth < history.current.length) {
414
+ const target = parseHash();
415
+ // a Back lands on one of OUR entries: its hash is the stack it was pushed with (the first N pages, N = the page
416
+ // entries below it). A typed hash or a plain <a href="#/route"> makes a new entry with no state (depth 0):
417
+ // that is a navigation to the hash, not a Back to the root
418
+ const pagesBelow = history.current.slice(0, depth).filter((k) => k === "page").length;
419
+ const back = depth < history.current.length && target.join("/") === live.current.stack.slice(0, pagesBelow).join("/");
420
+ if (back) {
409
421
  // back: unwind our entries down to depth
410
422
  void (async () => {
411
423
  while (history.current.length > depth) {
@@ -441,9 +453,8 @@ export const SkiaShell = forwardRef(function SkiaShell({ Routes, children, NavBa
441
453
  })();
442
454
  }
443
455
  else {
444
- // forward (depth grew), or the same depth with another hash = a plain <a href="#/route"> link or a typed hash:
456
+ // forward (depth grew), or another hash = a plain <a href="#/route"> link or a typed hash:
445
457
  // the hash names the pages, rebuild the stack without animation (overlays cannot be restored)
446
- const target = parseHash();
447
458
  if (depth === history.current.length && target.join("/") === live.current.stack.join("/"))
448
459
  return;
449
460
  history.current = target.map(() => "page");
@@ -466,28 +477,36 @@ export const SkiaShell = forwardRef(function SkiaShell({ Routes, children, NavBa
466
477
  const transitioning = transitions > 0 || leaving !== null;
467
478
  const rootBottom = hasTabs ? TabBarHeight + insets.Bottom : 0;
468
479
  const navTop = NavBarHeight + insets.Top;
469
- const rootNode = hasTabs ? RenderRoute(Tabs[selectedTab]?.route ?? "") : children;
480
+ // every tab keeps its root and its pages mounted (state, scroll positions), only the selected one shows (C# tab
481
+ // views, DrawnUi.Rust "tabs keep their stacks")
482
+ const tabIndexes = hasTabs ? Tabs.map((_, i) => i) : [0];
470
483
  const entering = tabTransition ? { TranslationX: tabTransition.dir * 0.75 * (typeof window !== "undefined" ? window.innerWidth : 0), Opacity: 0.001, ZIndex: 1 } : {};
471
- return (_jsx(ShellContext.Provider, { value: nav, children: _jsxs(SkiaLayer, { VerticalOptions: "Fill", children: [tabTransition && (_jsx(SkiaLayer, { VerticalOptions: "Fill", Margin: new Thickness(0, 0, 0, rootBottom), ZIndex: 0, BackgroundColor: PageBackgroundColor, ref: (c) => { if (c)
472
- tabRoots.current.set(tabTransition.from, c); }, children: RenderRoute(Tabs[tabTransition.from]?.route ?? "") }, `tab-leaving-${tabTransition.from}`)), _jsx(SkiaLayer, { VerticalOptions: "Fill", Margin: new Thickness(0, 0, 0, rootBottom), IsVisible: visiblePages.length === 0 || transitioning, BackgroundColor: hasTabs ? PageBackgroundColor : undefined, ...entering, ref: (c) => { if (c)
473
- tabRoots.current.set(selectedTab, c); }, children: rootNode }, hasTabs ? `tab-${selectedTab}` : "root"), visiblePages.map((r, i) => (_jsxs(PageHost, { route: r, speed: PagesAnimationSpeed, visible: r === topRoute || transitioning, background: PageBackgroundColor, bottom: rootBottom, register: (c) => { if (c)
484
+ return (_jsx(ShellContext.Provider, { value: nav, children: _jsxs(SkiaLayer, { VerticalOptions: "Fill", children: [tabIndexes.map((t) => {
485
+ const selected = t === selectedTab;
486
+ return (_jsx(SkiaLayer, { VerticalOptions: "Fill", Margin: new Thickness(0, 0, 0, rootBottom), BackgroundColor: hasTabs ? PageBackgroundColor : undefined, IsVisible: selected ? visiblePages.length === 0 || transitioning : tabTransition?.from === t, ZIndex: 0, ...(selected ? entering : {}), ref: (c) => { if (c)
487
+ tabRoots.current.set(t, c); }, children: hasTabs ? RenderRoute(Tabs[t]?.route ?? "") : children }, hasTabs ? `tab-${t}` : "root"));
488
+ }), tabIndexes.flatMap((t) => (t === selectedTab ? visiblePages : stacks[t] ?? []).map((r, i) => (_jsxs(PageHost, { route: r, speed: PagesAnimationSpeed, active: t === selectedTab, visible: t === selectedTab && (r === topRoute || transitioning), background: PageBackgroundColor, bottom: rootBottom, register: (c) => { if (c)
474
489
  pageCtrls.current.set(r, c);
475
490
  else
476
- pageCtrls.current.delete(r); }, children: [_jsx(SkiaLayer, { VerticalOptions: "Fill", Margin: new Thickness(0, navTop, 0, 0), children: RenderRoute(r) }), _jsxs(SkiaLayer, { HeightRequest: navTop, BackgroundColor: NavBarColor, Padding: new Thickness(0, insets.Top, 0, 0), children: [_jsx(SkiaButton, { Text: "\u2039 Back", BackgroundColor: "#00000000", TextColor: "#6EA8FE", FontSize: 16, VerticalOptions: "Center", Margin: new Thickness(8, 0), ApplyEffect: "Ripple", Tapped: () => void GoBackAsync(), AccessibilityRole: "button", AccessibilityLabel: "Back" }), _jsx(SkiaLabel, { Text: Titles?.[RouteName(r)] ?? RouteName(r), FontSize: 18, FontFamily: "FontTextBold", TextColor: Colors.White, HorizontalOptions: "Fill", HorizontalTextAlignment: "Center", VerticalOptions: "Center", MaxLines: 1, Margin: new Thickness(96, 0), AccessibilityRole: "heading" }), _jsx(SkiaButton, { Text: "Home", BackgroundColor: "#00000000", TextColor: "#6EA8FE", FontSize: 16, HorizontalOptions: "End", VerticalOptions: "Center", Margin: new Thickness(0, 0, 8, 0), ApplyEffect: "Ripple", Tapped: () => void PopToRootAsync(), AccessibilityRole: "button", AccessibilityLabel: "Home" }), _jsx(SkiaLayer, { HeightRequest: 1, VerticalOptions: "End", BackgroundColor: "#343A40" })] })] }, `${selectedTab}:${r}#${i}`))), hasTabs && (_jsxs(SkiaLayer, { HeightRequest: TabBarHeight + insets.Bottom, VerticalOptions: "End", BackgroundColor: TabBarColor, ZIndex: ShellDefaults.ZIndexModals - 1, BlockGesturesBelow: true, Padding: new Thickness(0, 0, 0, insets.Bottom), children: [_jsx(SkiaLayer, { HeightRequest: 1, BackgroundColor: "#343A40" }), _jsx(SkiaGrid, { ColumnDefinitions: Tabs.map(() => "*").join(","), HorizontalOptions: "Fill", VerticalOptions: "Fill", children: Tabs.map((t, i) => (_jsxs(SkiaLayer, { Column: i, HorizontalOptions: "Fill", VerticalOptions: "Fill", AnimationTapped: "Ripple", Tapped: () => void SelectTabAsync(i), AccessibilityRole: "button", AccessibilityLabel: t.title, children: [_jsx(SkiaLabel, { Text: t.title, FontSize: 13, FontFamily: i === selectedTab ? "FontTextBold" : undefined, TextColor: i === selectedTab ? ShellDefaults.TabSelectedColor : ShellDefaults.TabColor, HorizontalOptions: "Center", VerticalOptions: "Center", AccessibilityRole: "presentation" }), i === selectedTab && _jsx(SkiaShape, { Type: "Rectangle", HeightRequest: 3, WidthRequest: 36, CornerRadius: 2, BackgroundColor: ShellDefaults.TabSelectedColor, HorizontalOptions: "Center", VerticalOptions: "End" })] }, t.route))) })] })), modals.map((m) => m.node), popups.map((p) => p.node), toasts.map((t) => t.node)] }) }));
491
+ pageCtrls.current.delete(r); }, children: [_jsx(SkiaLayer, { VerticalOptions: "Fill", Margin: new Thickness(0, navTop, 0, 0), children: RenderRoute(r) }), _jsxs(SkiaLayer, { HeightRequest: navTop, BackgroundColor: NavBarColor, Padding: new Thickness(0, insets.Top, 0, 0), children: [_jsx(SkiaButton, { Text: "\u2039 Back", BackgroundColor: "#00000000", TextColor: "#6EA8FE", FontSize: 16, VerticalOptions: "Center", Margin: new Thickness(8, 0), ApplyEffect: "Ripple", Tapped: () => void GoBackAsync(), AccessibilityRole: "button", AccessibilityLabel: "Back" }), _jsx(SkiaLabel, { Text: Titles?.[RouteName(r)] ?? RouteName(r), FontSize: 18, FontFamily: "FontTextBold", TextColor: Colors.White, HorizontalOptions: "Fill", HorizontalTextAlignment: "Center", VerticalOptions: "Center", MaxLines: 1, Margin: new Thickness(96, 0), AccessibilityRole: "heading" }), _jsx(SkiaButton, { Text: "Home", BackgroundColor: "#00000000", TextColor: "#6EA8FE", FontSize: 16, HorizontalOptions: "End", VerticalOptions: "Center", Margin: new Thickness(0, 0, 8, 0), ApplyEffect: "Ripple", Tapped: () => void PopToRootAsync(), AccessibilityRole: "button", AccessibilityLabel: "Home" }), _jsx(SkiaLayer, { HeightRequest: 1, VerticalOptions: "End", BackgroundColor: "#343A40" })] })] }, `${t}:${r}#${i}`)))), hasTabs && (_jsxs(SkiaLayer, { HeightRequest: TabBarHeight + insets.Bottom, VerticalOptions: "End", BackgroundColor: TabBarColor, ZIndex: ShellDefaults.ZIndexModals - 1, BlockGesturesBelow: true, Padding: new Thickness(0, 0, 0, insets.Bottom), children: [_jsx(SkiaLayer, { HeightRequest: 1, BackgroundColor: "#343A40" }), _jsx(SkiaGrid, { ColumnDefinitions: Tabs.map(() => "*").join(","), HorizontalOptions: "Fill", VerticalOptions: "Fill", children: Tabs.map((t, i) => (_jsxs(SkiaLayer, { Column: i, HorizontalOptions: "Fill", VerticalOptions: "Fill", AnimationTapped: "Ripple", Tapped: () => void SelectTabAsync(i), AccessibilityRole: "button", AccessibilityLabel: t.title, children: [_jsx(SkiaLabel, { Text: t.title, FontSize: 13, FontFamily: i === selectedTab ? "FontTextBold" : undefined, TextColor: i === selectedTab ? ShellDefaults.TabSelectedColor : ShellDefaults.TabColor, HorizontalOptions: "Center", VerticalOptions: "Center", AccessibilityRole: "presentation" }), i === selectedTab && _jsx(SkiaShape, { Type: "Rectangle", HeightRequest: 3, WidthRequest: 36, CornerRadius: 2, BackgroundColor: ShellDefaults.TabSelectedColor, HorizontalOptions: "Center", VerticalOptions: "End" })] }, t.route))) })] })), modals.map((m) => m.node), popups.map((p) => p.node), toasts.map((t) => t.node)] }) }));
477
492
  });
478
493
  /** A pushed page: slides in from the right on mount (C# SkiaViewSwitcher PushView), covers what is below. */
479
- function PageHost({ route, speed, visible, background, bottom, register, children }) {
494
+ function PageHost({ route, speed, active, visible, background, bottom, register, children }) {
480
495
  const ctrl = useRef(null);
496
+ // slides in once, when pushed; a page of another tab stays mounted and is registered only while its tab is selected
481
497
  useEffect(() => {
482
498
  const c = ctrl.current;
483
- if (!c)
484
- return;
485
- register(c);
486
- if (speed > 0) {
499
+ if (c && speed > 0) {
487
500
  c.TranslationX = CanvasWidthPts(c);
488
501
  void c.TranslateToAsync(0, 0, speed);
489
502
  }
490
- return () => register(null);
491
503
  }, [route]); // eslint-disable-line react-hooks/exhaustive-deps
504
+ useEffect(() => {
505
+ const c = ctrl.current;
506
+ if (!c || !active)
507
+ return;
508
+ register(c);
509
+ return () => register(null);
510
+ }, [route, active]); // eslint-disable-line react-hooks/exhaustive-deps
492
511
  return (_jsx(SkiaLayer, { ref: (c) => { ctrl.current = c; }, VerticalOptions: "Fill", HorizontalOptions: "Fill", Margin: new Thickness(0, 0, 0, bottom), BackgroundColor: background, IsVisible: visible, BlockGesturesBelow: true, children: children }));
493
512
  }
@@ -126,6 +126,13 @@ const NAVIGATION_KEYS = new Set(["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDow
126
126
  * canvas. Keyboard and screen-reader input arrive as DOM events: Enter / Space activate (a Tapped), the navigation keys
127
127
  * go to the focused control and then to its arrow-key group; a group is one Tab stop (roving tabindex).
128
128
  */
129
+ /** aria-valuenow / min / max / valuetext of a range control (drawnui-cross 6c); a slider reads as horizontal. */
130
+ function valueAttributes(n) {
131
+ const v = n.Value;
132
+ if (!v)
133
+ return undefined;
134
+ return { "aria-valuenow": v.Now, "aria-valuemin": v.Min, "aria-valuemax": v.Max, "aria-valuetext": v.Text || undefined, "aria-orientation": n.Role === "slider" ? "horizontal" : undefined };
135
+ }
129
136
  function AccessibilityOverlay({ view }) {
130
137
  const [nodes, setNodes] = useState(() => view.AccessibilityManager.Snapshot);
131
138
  // keyboard focus moved by the manager (arrow keys in a group): focus that node's element once it is rendered
@@ -147,6 +154,23 @@ function AccessibilityOverlay({ view }) {
147
154
  setFocusRequest(undefined);
148
155
  elementOf(focusRequest.id)?.focus({ preventScroll: true });
149
156
  }, [focusRequest]);
157
+ // the focused node went away (its page or popup closed): the focus goes to the first Tab stop on screen in reading
158
+ // order instead of falling back to the page (drawnui-cross 6c refocus); a focus the user moved away stays away
159
+ const focusedId = useRef(0);
160
+ useLayoutEffect(() => {
161
+ const id = focusedId.current;
162
+ if (!id || nodes.some((n) => n.Id === id))
163
+ return;
164
+ focusedId.current = 0;
165
+ const active = document.activeElement;
166
+ if (active && active !== document.body)
167
+ return;
168
+ const box = overlayRef.current?.getBoundingClientRect();
169
+ const first = nodes.find((n) => n.CanInteract && view.AccessibilityManager.IsTabStop(n.Source)
170
+ && (!box || (n.Rect.Bottom > 0 && n.Rect.Top < box.height && n.Rect.Right > 0 && n.Rect.Left < box.width)));
171
+ if (first)
172
+ elementOf(first.Id)?.focus({ preventScroll: true });
173
+ }, [nodes]);
150
174
  // 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
175
  // field's own element (without taking the caret again) and the browser's Tab moves on from there
152
176
  const tabbingOut = useRef(0);
@@ -198,7 +222,7 @@ function AccessibilityOverlay({ view }) {
198
222
  return (_jsxs("div", { className: "drawnui-a11y-overlay", onScroll: pin, ref: overlayRef, children: [_jsx("style", { children: A11Y_CSS }), nodes.map((n) => {
199
223
  const pos = { left: n.Rect.Left, top: n.Rect.Top, width: n.Rect.Width, height: n.Rect.Height };
200
224
  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) => {
225
+ return n.CanInteract ? (_jsx("div", { "data-a11y-id": n.Id, role: n.Role, "aria-label": n.Label, title: n.Hint, ...valueAttributes(n), "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
226
  if (e.key === "Enter" || e.key === " ") {
203
227
  e.preventDefault();
204
228
  activate();
@@ -206,6 +230,7 @@ function AccessibilityOverlay({ view }) {
206
230
  else if (NAVIGATION_KEYS.has(e.key) && SkiaAccessibilityManager.Key(n.Source, e.key))
207
231
  e.preventDefault();
208
232
  }, onFocus: (e) => {
233
+ focusedId.current = n.Id;
209
234
  pin({ currentTarget: e.currentTarget.parentElement });
210
235
  SkiaScrollCtrl.EnsureVisible(n.Source); // inside every enclosing scroll
211
236
  if (tabbingOut.current === n.Id)
@@ -215,6 +240,8 @@ function AccessibilityOverlay({ view }) {
215
240
  n.Source.NotifyAccessibilityFocused(true);
216
241
  setFocusTick((t) => t + 1);
217
242
  }, onBlur: (e) => {
243
+ if (e.currentTarget.isConnected && focusedId.current === n.Id)
244
+ focusedId.current = 0; // moved away on purpose
218
245
  if (TextInputProxy.IsProxyTarget(e.relatedTarget))
219
246
  return; // a text field moved DOM focus to its own input
220
247
  n.Source.OnAccessibilityFocused(false);
@@ -223,7 +250,7 @@ function AccessibilityOverlay({ view }) {
223
250
  // AccessibilityTextSelectable: one positioned span per drawn line, in the drawn font, so the browser selects and copies it like HTML
224
251
  _jsx("div", { role: n.Role, title: n.Hint, "aria-disabled": n.Disabled || undefined, "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)) : (
225
252
  // static text is exposed as real (transparent) text content; aria-label only for roles that need a name
226
- _jsx("div", { role: n.Role, "aria-label": n.Role === "text" ? undefined : n.Label, title: n.Hint, "aria-disabled": n.Disabled || undefined, "aria-live": n.Live, className: "drawnui-a11y-node", style: pos, children: n.Label }, n.Id));
253
+ _jsx("div", { role: n.Role, "aria-label": n.Role === "text" ? undefined : n.Label, title: n.Hint, ...valueAttributes(n), "aria-disabled": n.Disabled || undefined, "aria-live": n.Live, className: "drawnui-a11y-node", style: pos, children: n.Label }, n.Id));
227
254
  })] }));
228
255
  }
229
256
  // One import for apps: React tags + every engine type (Colors, Thickness, Super, gestures, animators...).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drawnui-react",
3
- "version": "0.1.0-preview.16",
3
+ "version": "0.1.0-preview.17",
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)",
@@ -59,6 +59,20 @@
59
59
  "check:children": "node dev/check.mjs dev/check-children.tsx",
60
60
  "check:composite": "node dev/check.mjs dev/check-composite.tsx",
61
61
  "check:hover": "node dev/check.mjs dev/check-hover.ts",
62
+ "check:grid-label": "node dev/check.mjs dev/check-grid-label.ts",
63
+ "check:snap": "node dev/check.mjs dev/check-snap.ts",
64
+ "check:game": "node dev/check.mjs dev/check-game.ts",
65
+ "check:animation": "node dev/check.mjs dev/check-animation.ts",
66
+ "check:shape": "node dev/check.mjs dev/check-shape.ts",
67
+ "check:label": "node dev/check.mjs dev/check-label.ts",
68
+ "check:image": "node dev/check.mjs dev/check-image.ts",
69
+ "check:longpress": "node dev/check.mjs dev/check-longpress.ts",
70
+ "check:hidden": "node dev/check.mjs dev/check-hidden.ts",
71
+ "check:shell": "node dev/check.mjs dev/check-shell.tsx",
72
+ "check:wheel-routing": "node dev/check.mjs dev/check-wheel-routing.ts",
73
+ "check:velocity": "node dev/check.mjs dev/check-velocity.ts",
74
+ "check:scroll-index": "node dev/check.mjs dev/check-scroll-index.ts",
75
+ "check:culling": "node dev/check.mjs dev/check-culling.ts",
62
76
  "site:extras": "node dev/site-extras.mjs dist/demo",
63
77
  "build:lib": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
64
78
  "prepublishOnly": "npm run build:lib",