@react-x11/components 0.13.0 → 0.14.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.
Files changed (128) hide show
  1. package/dist/html/controls.d.ts +29 -2
  2. package/dist/html/controls.d.ts.map +1 -1
  3. package/dist/html/controls.js +70 -2
  4. package/dist/html/controls.js.map +1 -1
  5. package/dist/html/css/cascade.d.ts.map +1 -1
  6. package/dist/html/css/cascade.js +26 -4
  7. package/dist/html/css/cascade.js.map +1 -1
  8. package/dist/html/css/color.d.ts +18 -0
  9. package/dist/html/css/color.d.ts.map +1 -1
  10. package/dist/html/css/color.js +27 -0
  11. package/dist/html/css/color.js.map +1 -1
  12. package/dist/html/css/parse.d.ts +18 -4
  13. package/dist/html/css/parse.d.ts.map +1 -1
  14. package/dist/html/css/parse.js +66 -28
  15. package/dist/html/css/parse.js.map +1 -1
  16. package/dist/html/css/style.d.ts +128 -15
  17. package/dist/html/css/style.d.ts.map +1 -1
  18. package/dist/html/css/style.js +561 -144
  19. package/dist/html/css/style.js.map +1 -1
  20. package/dist/html/css/transform.d.ts +85 -0
  21. package/dist/html/css/transform.d.ts.map +1 -0
  22. package/dist/html/css/transform.js +425 -0
  23. package/dist/html/css/transform.js.map +1 -0
  24. package/dist/html/css/ua.d.ts.map +1 -1
  25. package/dist/html/css/ua.js +14 -2
  26. package/dist/html/css/ua.js.map +1 -1
  27. package/dist/html/css/values.d.ts +4 -0
  28. package/dist/html/css/values.d.ts.map +1 -1
  29. package/dist/html/css/values.js.map +1 -1
  30. package/dist/html/fonts.d.ts +55 -5
  31. package/dist/html/fonts.d.ts.map +1 -1
  32. package/dist/html/fonts.js +284 -23
  33. package/dist/html/fonts.js.map +1 -1
  34. package/dist/html/layout/axes.d.ts +14 -0
  35. package/dist/html/layout/axes.d.ts.map +1 -0
  36. package/dist/html/layout/axes.js +28 -0
  37. package/dist/html/layout/axes.js.map +1 -0
  38. package/dist/html/layout/block.d.ts +93 -12
  39. package/dist/html/layout/block.d.ts.map +1 -1
  40. package/dist/html/layout/block.js +389 -39
  41. package/dist/html/layout/block.js.map +1 -1
  42. package/dist/html/layout/boxes.d.ts +59 -1
  43. package/dist/html/layout/boxes.d.ts.map +1 -1
  44. package/dist/html/layout/boxes.js +22 -2
  45. package/dist/html/layout/boxes.js.map +1 -1
  46. package/dist/html/layout/cache.d.ts +1 -1
  47. package/dist/html/layout/cache.d.ts.map +1 -1
  48. package/dist/html/layout/cache.js +6 -0
  49. package/dist/html/layout/cache.js.map +1 -1
  50. package/dist/html/layout/css-grid.d.ts.map +1 -1
  51. package/dist/html/layout/css-grid.js +90 -18
  52. package/dist/html/layout/css-grid.js.map +1 -1
  53. package/dist/html/layout/flex.d.ts.map +1 -1
  54. package/dist/html/layout/flex.js +570 -83
  55. package/dist/html/layout/flex.js.map +1 -1
  56. package/dist/html/layout/inline.d.ts +3 -0
  57. package/dist/html/layout/inline.d.ts.map +1 -1
  58. package/dist/html/layout/inline.js +144 -54
  59. package/dist/html/layout/inline.js.map +1 -1
  60. package/dist/html/layout/multicol.d.ts +42 -0
  61. package/dist/html/layout/multicol.d.ts.map +1 -0
  62. package/dist/html/layout/multicol.js +897 -0
  63. package/dist/html/layout/multicol.js.map +1 -0
  64. package/dist/html/layout/shaping.d.ts +7 -0
  65. package/dist/html/layout/shaping.d.ts.map +1 -1
  66. package/dist/html/layout/shaping.js +25 -20
  67. package/dist/html/layout/shaping.js.map +1 -1
  68. package/dist/html/layout/table.d.ts.map +1 -1
  69. package/dist/html/layout/table.js +62 -18
  70. package/dist/html/layout/table.js.map +1 -1
  71. package/dist/html/node.d.ts +3 -0
  72. package/dist/html/node.d.ts.map +1 -1
  73. package/dist/html/node.js +263 -36
  74. package/dist/html/node.js.map +1 -1
  75. package/dist/html/paint.d.ts +79 -4
  76. package/dist/html/paint.d.ts.map +1 -1
  77. package/dist/html/paint.js +1213 -141
  78. package/dist/html/paint.js.map +1 -1
  79. package/dist/html/svg.d.ts +15 -2
  80. package/dist/html/svg.d.ts.map +1 -1
  81. package/dist/html/svg.js +249 -17
  82. package/dist/html/svg.js.map +1 -1
  83. package/dist/html/widgets.js +87 -7
  84. package/dist/html/widgets.js.map +1 -1
  85. package/dist/html/woff2.d.ts +14 -0
  86. package/dist/html/woff2.d.ts.map +1 -0
  87. package/dist/html/woff2.js +608 -0
  88. package/dist/html/woff2.js.map +1 -0
  89. package/dist/maps/controller.d.ts +17 -1
  90. package/dist/maps/controller.d.ts.map +1 -1
  91. package/dist/maps/controller.js +27 -7
  92. package/dist/maps/controller.js.map +1 -1
  93. package/dist/richtext/node.d.ts +8 -1
  94. package/dist/richtext/node.d.ts.map +1 -1
  95. package/dist/richtext/node.js +5 -1
  96. package/dist/richtext/node.js.map +1 -1
  97. package/dist/richtext/runs.d.ts +25 -6
  98. package/dist/richtext/runs.d.ts.map +1 -1
  99. package/dist/richtext/runs.js +240 -24
  100. package/dist/richtext/runs.js.map +1 -1
  101. package/package.json +1 -1
  102. package/src/html/controls.ts +85 -2
  103. package/src/html/css/cascade.ts +23 -0
  104. package/src/html/css/color.ts +29 -0
  105. package/src/html/css/parse.ts +71 -31
  106. package/src/html/css/style.ts +664 -147
  107. package/src/html/css/transform.ts +459 -0
  108. package/src/html/css/ua.ts +15 -2
  109. package/src/html/css/values.ts +4 -0
  110. package/src/html/fonts.ts +312 -29
  111. package/src/html/layout/axes.ts +62 -0
  112. package/src/html/layout/block.ts +444 -37
  113. package/src/html/layout/boxes.ts +65 -3
  114. package/src/html/layout/cache.ts +6 -0
  115. package/src/html/layout/css-grid.ts +95 -22
  116. package/src/html/layout/flex.ts +671 -77
  117. package/src/html/layout/inline.ts +149 -55
  118. package/src/html/layout/multicol.ts +977 -0
  119. package/src/html/layout/shaping.ts +27 -18
  120. package/src/html/layout/table.ts +62 -19
  121. package/src/html/node.ts +300 -37
  122. package/src/html/paint.ts +1406 -206
  123. package/src/html/svg.ts +277 -19
  124. package/src/html/widgets.ts +95 -9
  125. package/src/html/woff2.ts +612 -0
  126. package/src/maps/controller.ts +28 -7
  127. package/src/richtext/node.ts +13 -2
  128. package/src/richtext/runs.ts +313 -35
package/src/html/svg.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  // as `preserveAspectRatio` says, and clipped to it.
15
15
  import { parseDocument } from 'htmlparser2';
16
16
  import { Element, Text } from 'domhandler';
17
- import type { ChildNode } from 'domhandler';
17
+ import type { ChildNode, ParentNode } from 'domhandler';
18
18
  import * as ntk from 'react-x11/ntk';
19
19
  import { isSvgRoot } from './dom.js';
20
20
  import { inkColor, isTransparent, parseColor } from './css/values.js';
@@ -183,9 +183,18 @@ export class SvgDrawing {
183
183
  private _seen = -1;
184
184
  private _seenLast: ChildNode | null = null;
185
185
  private _seenViewport = '';
186
+ /** The `fill` and `stroke` the view's tree was last given its root's. */
187
+ private _seenPaint = '';
186
188
  /** Whether a length in the tree is a percentage of the viewport; null
187
189
  * until the tree is first read. */
188
190
  private _percent: boolean | null = null;
191
+ /** Whether a `<use>` in the tree is one `SvgView` cannot draw as it
192
+ * stands (`needsExpanding`). */
193
+ private _uses = false;
194
+ /** The document's last node when a `<use>` was last looked up and its
195
+ * element not found: a streamed document may bring it yet, and has
196
+ * when its last node is another. Undefined where none was missing. */
197
+ private _missingAt: ChildNode | null | undefined = undefined;
189
198
  private _failed = false;
190
199
  /** An SVG image's own document, rather than an element of this one. */
191
200
  private readonly _standalone: boolean;
@@ -203,7 +212,11 @@ export class SvgDrawing {
203
212
  * Draw into a rectangle — the box's content box, in device pixels —
204
213
  * clipped to it. The `viewBox`, where there is one, is fitted to it as
205
214
  * `preserveAspectRatio` says; without one a user unit is a CSS pixel,
206
- * which is `scale` device pixels. `color` is `currentColor`.
215
+ * which is `scale` device pixels. `color` is `currentColor`, and `fill`
216
+ * and `stroke` the root's own where the document's styles set them (SVG
217
+ * 2, 13.2): they are properties, which a style sheet's rule sets over
218
+ * the presentation attribute, and what is in the drawing inherits them
219
+ * from its root.
207
220
  */
208
221
  draw(
209
222
  ctx: ClipContext,
@@ -213,6 +226,8 @@ export class SvgDrawing {
213
226
  h: number,
214
227
  scale: number,
215
228
  color?: string,
229
+ fill: string | null = null,
230
+ stroke: string | null = null,
216
231
  ): void {
217
232
  if (this._failed || !(w > 0 && h > 0)) return;
218
233
  // the mock backend has no path API, and SvgView draws paths
@@ -242,9 +257,12 @@ export class SvgDrawing {
242
257
  }
243
258
  const box = viewBoxOf(root);
244
259
  // the viewport in user units, which a percentage is of
260
+ const paint =
261
+ (fill === null ? '' : `fill:${fill};`) +
262
+ (stroke === null ? '' : `stroke:${stroke};`);
245
263
  const view = box
246
- ? this._viewFor(box[2], box[3])
247
- : this._viewFor(w / scale, h / scale);
264
+ ? this._viewFor(box[2], box[3], paint)
265
+ : this._viewFor(w / scale, h / scale, paint);
248
266
  if (!view) return;
249
267
  ctx.save();
250
268
  try {
@@ -294,14 +312,32 @@ export class SvgDrawing {
294
312
  }
295
313
  }
296
314
 
297
- private _viewFor(width: number, height: number): SvgViewLike | null {
315
+ private _viewFor(
316
+ width: number,
317
+ height: number,
318
+ /** The root's `fill` and `stroke` as declarations, or none. */
319
+ paint = '',
320
+ ): SvgViewLike | null {
298
321
  const root = this._root;
299
322
  const count = root.children.length;
300
323
  const last = root.lastChild;
301
- const grown = count !== this._seen || last !== this._seenLast;
302
- if (grown || this._percent === null) this._percent = hasPercent(root);
303
- const viewport = this._percent ? `${width}x${height}` : '';
304
- if (this._view && !grown && viewport === this._seenViewport) {
324
+ // more of the document has arrived since a `<use>` found nothing
325
+ const arrived =
326
+ this._missingAt !== undefined &&
327
+ lastNode(documentOf(root)) !== this._missingAt;
328
+ const grown = count !== this._seen || last !== this._seenLast || arrived;
329
+ if (grown || this._percent === null) {
330
+ this._percent = hasPercent(root);
331
+ this._uses = needsExpanding(root);
332
+ }
333
+ const sized = this._percent || this._uses;
334
+ const viewport = sized ? `${width}x${height}` : '';
335
+ if (
336
+ this._view &&
337
+ !grown &&
338
+ viewport === this._seenViewport &&
339
+ paint === this._seenPaint
340
+ ) {
305
341
  return this._view;
306
342
  }
307
343
  const View = svgViewClass();
@@ -311,15 +347,18 @@ export class SvgDrawing {
311
347
  }
312
348
  try {
313
349
  const view = this._view ?? new View(null);
314
- view.setSvgDom(
315
- this._percent || root.name.includes(':')
316
- ? copyTree(root, this._percent ? [width, height] : null)
317
- : root,
318
- );
350
+ const missing = { any: false };
351
+ const tree =
352
+ sized || root.name.includes(':')
353
+ ? copyTree(root, sized ? [width, height] : null, missing)
354
+ : root;
355
+ view.setSvgDom(paint ? painted(tree, paint) : tree);
356
+ this._missingAt = missing.any ? lastNode(documentOf(root)) : undefined;
319
357
  this._view = view;
320
358
  this._seen = count;
321
359
  this._seenLast = last;
322
360
  this._seenViewport = viewport;
361
+ this._seenPaint = paint;
323
362
  return view;
324
363
  } catch {
325
364
  this._failed = true;
@@ -328,6 +367,21 @@ export class SvgDrawing {
328
367
  }
329
368
  }
330
369
 
370
+ /**
371
+ * A drawing's root with its `fill` and `stroke` the document's: a copy of
372
+ * the root, its children the same ones, with the declarations at the end of
373
+ * its `style` — where `SvgView` reads a root's paint, over its presentation
374
+ * attributes, for what is under it to inherit.
375
+ */
376
+ function painted(root: Element, paint: string): Element {
377
+ const style = root.attribs.style;
378
+ return new Element(
379
+ root.name,
380
+ { ...root.attribs, style: style ? `${style};${paint}` : paint },
381
+ root.children,
382
+ );
383
+ }
384
+
331
385
  /**
332
386
  * The colour an SVG image's root element sets as its background, in its
333
387
  * `style` attribute — the one place an image can say it, since
@@ -413,13 +467,154 @@ function hasPercent(root: Element): boolean {
413
467
  return walk(root);
414
468
  }
415
469
 
470
+ /** The element a `<use>` refers to by a fragment, its id: one in another
471
+ * document is none here, where nothing is fetched for a drawing. */
472
+ function useTarget(el: Element): string | null {
473
+ const href = el.attribs.href ?? el.attribs['xlink:href'] ?? '';
474
+ return href.length > 1 && href.startsWith('#') ? href.slice(1) : null;
475
+ }
476
+
477
+ /** The elements under a root by their ids, the first of each. */
478
+ function idsUnder(root: Element): Map<string, Element> {
479
+ const ids = new Map<string, Element>();
480
+ const walk = (el: Element): void => {
481
+ for (const child of el.children) {
482
+ if (child.type !== 'tag') continue;
483
+ const tag = child as Element;
484
+ const id = tag.attribs.id;
485
+ if (id && !ids.has(id)) ids.set(id, tag);
486
+ walk(tag);
487
+ }
488
+ };
489
+ walk(root);
490
+ return ids;
491
+ }
492
+
493
+ /**
494
+ * Whether a `<use>` under the root is one `SvgView` cannot draw as it
495
+ * stands: it looks a reference up among the root's own elements, and draws
496
+ * a `<symbol>` as its children where they are. So one to an element
497
+ * outside the root — an icon sprite's, a hidden `<svg>` of symbols at the
498
+ * top of the page — and one to a symbol with a `viewBox` of its own are
499
+ * drawn from a copy (`copyTree`).
500
+ */
501
+ function needsExpanding(root: Element): boolean {
502
+ let ids: Map<string, Element> | null = null;
503
+ const walk = (el: Element): boolean => {
504
+ for (const child of el.children) {
505
+ if (child.type !== 'tag') continue;
506
+ const tag = child as Element;
507
+ if (localName(tag.name) === 'use') {
508
+ const id = useTarget(tag);
509
+ if (id !== null) {
510
+ ids ??= idsUnder(root);
511
+ const target = ids.get(id);
512
+ if (!target) return true;
513
+ if (localName(target.name) === 'symbol' && viewBoxOf(target)) {
514
+ return true;
515
+ }
516
+ }
517
+ }
518
+ if (walk(tag)) return true;
519
+ }
520
+ return false;
521
+ };
522
+ return walk(root);
523
+ }
524
+
525
+ function documentOf(el: Element): ParentNode {
526
+ let top: ParentNode = el;
527
+ while (top.parent) top = top.parent;
528
+ return top;
529
+ }
530
+
531
+ /** The last node of a document, in its order: what a document that is
532
+ * still arriving has another of once more of it has. */
533
+ function lastNode(top: ParentNode): ChildNode | null {
534
+ let last: ChildNode | null = top.lastChild;
535
+ while (last && 'lastChild' in last && last.lastChild) last = last.lastChild;
536
+ return last;
537
+ }
538
+
539
+ /** The first element of a document with an id, in its order. */
540
+ function elementById(top: ParentNode, id: string): Element | null {
541
+ const stack: ChildNode[] = [...top.children].reverse();
542
+ for (let node = stack.pop(); node; node = stack.pop()) {
543
+ if (node.type !== 'tag') continue;
544
+ const el = node as Element;
545
+ if (el.attribs.id === id) return el;
546
+ for (let i = el.children.length - 1; i >= 0; i -= 1) {
547
+ stack.push(el.children[i]);
548
+ }
549
+ }
550
+ return null;
551
+ }
552
+
553
+ /** How deep a `<use>` may be of a `<use>`: one that reaches itself stops. */
554
+ const USE_DEPTH = 8;
555
+
556
+ /** A `<use>`'s `width` or `height`, in user units: a number, or a
557
+ * percentage of the viewport's; null for none, and for `auto`. */
558
+ function useLength(value: string | undefined, of: number): number | null {
559
+ if (value === undefined) return null;
560
+ const n = parseFloat(value);
561
+ if (!Number.isFinite(n) || n < 0) return null;
562
+ return value.trim().endsWith('%') ? (n / 100) * of : n;
563
+ }
564
+
565
+ /** The transform that fits a `viewBox` into a viewport at `x`, `y`, as
566
+ * `preserveAspectRatio` says (SVG 2, 8.2). */
567
+ function fitTransform(
568
+ box: [number, number, number, number],
569
+ x: number,
570
+ y: number,
571
+ width: number,
572
+ height: number,
573
+ preserve: string | undefined,
574
+ ): string {
575
+ const fit = (preserve ?? '').trim().split(/\s+/);
576
+ if (fit[0] === 'defer') fit.shift();
577
+ let sx = width / box[2];
578
+ let sy = height / box[3];
579
+ if (fit[0] !== 'none') {
580
+ const align = ALIGN.exec(fit[0] ?? '') ?? ['xMidYMid', 'Mid', 'Mid'];
581
+ const scale = fit[1] === 'slice' ? Math.max(sx, sy) : Math.min(sx, sy);
582
+ x += (width - box[2] * scale) * AT[align[1]];
583
+ y += (height - box[3] * scale) * AT[align[2]];
584
+ sx = scale;
585
+ sy = scale;
586
+ }
587
+ return `translate(${x - box[0] * sx},${y - box[1] * sy}) scale(${sx},${sy})`;
588
+ }
589
+
590
+ /** What a `<use>` says of where its element goes, and not of how it looks:
591
+ * the rest is the group's the element is drawn in. */
592
+ const USE_PLACEMENT = new Set([
593
+ 'href',
594
+ 'xlink:href',
595
+ 'x',
596
+ 'y',
597
+ 'width',
598
+ 'height',
599
+ ]);
600
+
416
601
  /**
417
602
  * The tree `SvgView` reads, where the document's own will not do: with
418
603
  * local names — `SvgView` knows `rect`, not `svg:rect`, and a prefix is
419
604
  * dropped only where it is bound to SVG — and with its percentages resolved
420
- * against a viewport of `[width, height]` user units, where one is given.
605
+ * against a viewport of `[width, height]` user units, where one is given;
606
+ * and with each `<use>` that `SvgView` cannot draw (`needsExpanding`) as a
607
+ * group of what it refers to (SVG 2, 5.5): the element, from wherever in
608
+ * the document it is, or a symbol's children, its `viewBox` fitted to the
609
+ * viewport the `<use>` gives it — its `width` and `height`, and all of the
610
+ * drawing's where it has none. `missing.any` is set where one refers to an
611
+ * element the document does not have.
421
612
  */
422
- function copyTree(root: Element, viewport: [number, number] | null): Element {
613
+ function copyTree(
614
+ root: Element,
615
+ viewport: [number, number] | null,
616
+ missing?: { any: boolean },
617
+ ): Element {
423
618
  const colon = root.name.indexOf(':');
424
619
  const prefix = colon < 0 ? null : `${root.name.slice(0, colon)}:`;
425
620
  const strip = (name: string): string =>
@@ -427,7 +622,69 @@ function copyTree(root: Element, viewport: [number, number] | null): Element {
427
622
  const diagonal = viewport
428
623
  ? Math.sqrt((viewport[0] ** 2 + viewport[1] ** 2) / 2)
429
624
  : 0;
430
- const copy = (el: Element, resolve: boolean): Element => {
625
+ let ids: Map<string, Element> | null = null;
626
+ const expand = (
627
+ use: Element,
628
+ resolve: boolean,
629
+ depth: number,
630
+ ): Element | null => {
631
+ const id = useTarget(use);
632
+ if (id === null || depth >= USE_DEPTH) return null;
633
+ ids ??= idsUnder(root);
634
+ const inside = ids.get(id);
635
+ const target = inside ?? elementById(documentOf(root), id);
636
+ if (!target) {
637
+ if (missing) missing.any = true;
638
+ return null;
639
+ }
640
+ const symbol = localName(target.name) === 'symbol';
641
+ const box = symbol ? viewBoxOf(target) : null;
642
+ // one of the root's own with no viewport to fit: `SvgView` draws it
643
+ if (inside && !box) return null;
644
+ const attribs: Record<string, string> = {};
645
+ for (const name in use.attribs) {
646
+ if (!USE_PLACEMENT.has(name)) attribs[name] = use.attribs[name];
647
+ }
648
+ const x = parseFloat(use.attribs.x ?? '') || 0;
649
+ const y = parseFloat(use.attribs.y ?? '') || 0;
650
+ let placed = '';
651
+ if (box && viewport) {
652
+ const width =
653
+ useLength(use.attribs.width, viewport[0]) ??
654
+ useLength(target.attribs.width, viewport[0]) ??
655
+ viewport[0];
656
+ const height =
657
+ useLength(use.attribs.height, viewport[1]) ??
658
+ useLength(target.attribs.height, viewport[1]) ??
659
+ viewport[1];
660
+ placed = fitTransform(
661
+ box,
662
+ x,
663
+ y,
664
+ width,
665
+ height,
666
+ svgAttr(target, 'preserveAspectRatio'),
667
+ );
668
+ } else if (x || y) placed = `translate(${x},${y})`;
669
+ const transform = `${attribs.transform ?? ''} ${placed}`.trim();
670
+ if (transform) attribs.transform = transform;
671
+ // a length in a symbol with a `viewBox` is of that, and left as it is
672
+ const within = resolve && !box;
673
+ const children: ChildNode[] = [];
674
+ if (symbol) {
675
+ for (const child of target.children) {
676
+ if (child.type === 'tag') {
677
+ children.push(copy(child as Element, within, depth + 1));
678
+ }
679
+ }
680
+ } else children.push(copy(target, within, depth + 1));
681
+ return new Element('g', attribs, children);
682
+ };
683
+ const copy = (el: Element, resolve: boolean, depth = 0): Element => {
684
+ if (localName(el.name) === 'use') {
685
+ const group = expand(el, resolve, depth);
686
+ if (group) return group;
687
+ }
431
688
  const here = resolve && !OWN_UNITS.has(localName(el.name));
432
689
  const attribs = { ...el.attribs };
433
690
  if (here && viewport && el !== root) {
@@ -444,8 +701,9 @@ function copyTree(root: Element, viewport: [number, number] | null): Element {
444
701
  }
445
702
  const children: ChildNode[] = [];
446
703
  for (const child of el.children) {
447
- if (child.type === 'tag') children.push(copy(child as Element, here));
448
- else if (child.type === 'text') children.push(new Text(child.data));
704
+ if (child.type === 'tag') {
705
+ children.push(copy(child as Element, here, depth));
706
+ } else if (child.type === 'text') children.push(new Text(child.data));
449
707
  }
450
708
  return new Element(strip(el.name), attribs, children);
451
709
  };
@@ -25,11 +25,11 @@ import type {} from 'react-x11/jsx-runtime';
25
25
 
26
26
  import { cancelLater, later } from '../internal/timers.js';
27
27
  import { hx } from './hx.js';
28
- import { attr, tagOf } from './dom.js';
28
+ import { attr, isElement, tagOf } from './dom.js';
29
29
  import type { Document, Element } from './dom.js';
30
30
  import type { HtmlViewNode } from './node.js';
31
31
  import type { RootLook } from './css/style.js';
32
- import { buttonLabel, optionsOf, selectedOption } from './controls.js';
32
+ import { between, buttonLabel, optionsOf, selectedOption } from './controls.js';
33
33
  import type { BareField, ControlRect } from './controls.js';
34
34
  import {
35
35
  FormState,
@@ -433,12 +433,28 @@ function renderControl(
433
433
  const readOnly = attr(el, 'readonly') !== undefined;
434
434
  // a field whose box the document draws takes its content box
435
435
  const at = rect.bare ?? rect;
436
+ const left = Math.round(at.x);
437
+ const top = Math.round(at.y);
438
+ const width = Math.round(at.width);
439
+ const height = Math.round(at.height);
440
+ // What the widget shows through: its own rectangle, or where the
441
+ // document cuts the element, what the cut leaves of it — out to where a
442
+ // focus ring reaches, which a clip around the element cuts as it cuts
443
+ // the element, and one that leaves the box whole does not.
444
+ const port = rect.clip
445
+ ? between(rect.clip, {
446
+ x: left - RING_REACH,
447
+ y: top - RING_REACH,
448
+ width: width + 2 * RING_REACH,
449
+ height: height + 2 * RING_REACH,
450
+ })
451
+ : { x: left, y: top, width, height };
436
452
  const frame: Style = {
437
453
  position: 'absolute',
438
- left: Math.round(at.x),
439
- top: Math.round(at.y),
440
- width: Math.round(at.width),
441
- height: Math.round(at.height),
454
+ left: left - port.x,
455
+ top: top - port.y,
456
+ width,
457
+ height,
442
458
  // The face and the size the box was measured in, the element's: the
443
459
  // palette's from the UA sheet, or the page's where it set its own. A
444
460
  // field inherits them, and so does a `<Button>`'s or a `<Select>`'s
@@ -452,6 +468,7 @@ function renderControl(
452
468
  ...(rect.opacity !== undefined && { opacity: rect.opacity }),
453
469
  };
454
470
  const field = rect.bare ? bareField(rect.bare) : fieldChrome(look);
471
+ const order = tabOrder(el);
455
472
  // A button or a select whose font the page set is the drawn control: a
456
473
  // native bezel sets its title at AppKit's size, whatever it is handed,
457
474
  // in a bezel only as tall as that title, where the box was measured for
@@ -474,6 +491,7 @@ function renderControl(
474
491
  widget = h(Checkbox, {
475
492
  checked: attr(el, 'checked') !== undefined,
476
493
  disabled,
494
+ ...order,
477
495
  // the element's whole box takes the press, as it does in a browser:
478
496
  // a page that sizes one over its label, invisible, means the label
479
497
  style: { width: '100%', height: '100%' },
@@ -482,6 +500,9 @@ function renderControl(
482
500
  break;
483
501
  case 'radio': {
484
502
  const value = attr(el, 'value') ?? 'on';
503
+ // No `tabindex` reaches a radio: core's `<Radio>` takes no props for
504
+ // the node it draws, as the other widgets do, and the group's box is
505
+ // not the one that takes the focus.
485
506
  widget = h(
486
507
  RadioGroup,
487
508
  {
@@ -496,6 +517,7 @@ function renderControl(
496
517
  widget = h(Button, {
497
518
  label: buttonLabel(el),
498
519
  disabled,
520
+ ...order,
499
521
  ...(ownFont && { native: false }),
500
522
  style: { width: '100%', height: '100%' },
501
523
  onPress: () => ctx.press(el),
@@ -506,7 +528,11 @@ function renderControl(
506
528
  widget = h(Select, {
507
529
  options: options.map((o) => ({ value: o.value, label: o.label })),
508
530
  value: selectedOption(el) ?? undefined,
531
+ // a `<select>` with nothing selected has no options, and shows
532
+ // none: core's "Select…" is an application's prompt, not a page's
533
+ placeholder: '',
509
534
  disabled,
535
+ ...order,
510
536
  style: rect.bare
511
537
  ? [BARE_TRIGGER, { width: '100%', height: '100%' }]
512
538
  : { width: '100%', height: '100%' },
@@ -547,6 +573,7 @@ function renderControl(
547
573
  widget = hx('textarea', {
548
574
  defaultValue: forms.value(el),
549
575
  maxLength: maxLength(el),
576
+ ...order,
550
577
  style: [field, { width: '100%', height: '100%' }],
551
578
  onChange: readOnly ? undefined : (ev) => typed(ev.value),
552
579
  });
@@ -557,6 +584,7 @@ function renderControl(
557
584
  defaultValue: forms.value(el),
558
585
  placeholder: attr(el, 'placeholder'),
559
586
  maxLength: maxLength(el),
587
+ ...order,
560
588
  // Core's word for a password field: nothing in it reaches a
561
589
  // selection, PRIMARY included.
562
590
  sensitive: type === 'password',
@@ -578,18 +606,76 @@ function renderControl(
578
606
  default:
579
607
  return null;
580
608
  }
609
+ // Two boxes, always: the one the widget shows through, and in it the
610
+ // element's. A widget the document comes to cut, or stops cutting, is
611
+ // the same widget in the same place in the tree, and keeps its focus
612
+ // and its caret.
581
613
  return hx(
582
614
  'box',
583
615
  {
584
616
  key,
585
- ref: ctx.widgets.refOf(el),
586
- style: frame,
587
617
  selectable: false,
618
+ style: {
619
+ position: 'absolute',
620
+ left: port.x,
621
+ top: port.y,
622
+ width: port.width,
623
+ height: port.height,
624
+ // cut to it, and nothing itself: a press beside the widget is the
625
+ // document's
626
+ ...(rect.clip && { overflow: 'hidden', pointerEvents: 'box-none' }),
627
+ },
588
628
  },
589
- widget,
629
+ hx(
630
+ 'box',
631
+ {
632
+ ref: ctx.widgets.refOf(el),
633
+ style: frame,
634
+ selectable: false,
635
+ // a control the page keeps from assistive technology is kept from
636
+ // it here: core leaves the node, and the widget in it, out of the
637
+ // accessibility tree
638
+ ...(ariaHidden(el) && { 'aria-hidden': true }),
639
+ },
640
+ widget,
641
+ ),
590
642
  );
591
643
  }
592
644
 
645
+ /**
646
+ * What an element's `tabindex` says of its widget's place in the Tab order
647
+ * (HTML 6.6.3): a negative one is focusable — by a press, by its label, by
648
+ * `autofocus` — and not reached by Tab, which is core's `tabIndex={-1}`
649
+ * too. Radix lays a native `<select tabindex="-1" aria-hidden="true">`
650
+ * beside the picker it draws, cut to nothing: it was a stop the eye could
651
+ * not find, and Space on it opened an empty menu.
652
+ *
653
+ * Nothing else is handed over. Zero is where a control already is, and a
654
+ * positive one would put a page's control ahead of the application's own,
655
+ * whose window it is: the order between a document and what is around it
656
+ * is not the document's to set.
657
+ */
658
+ function tabOrder(el: Element): { tabIndex: -1 } | undefined {
659
+ // the rules for parsing integers (HTML 2.3.4.1): white space, a sign,
660
+ // digits, and whatever follows them ignored
661
+ const m = /^[ \t\n\f\r]*([+-]?\d+)/.exec(attr(el, 'tabindex') ?? '');
662
+ return m && Number(m[1]) < 0 ? { tabIndex: -1 } : undefined;
663
+ }
664
+
665
+ /** Whether an element is hidden from assistive technology: `aria-hidden`
666
+ * is true on it or on an element around it (WAI-ARIA 1.2, 6.6). */
667
+ function ariaHidden(el: Element): boolean {
668
+ for (let at: Element | null = el; at;) {
669
+ if (attr(at, 'aria-hidden')?.trim().toLowerCase() === 'true') return true;
670
+ const parent: Element['parent'] = at.parent;
671
+ at = isElement(parent) ? parent : null;
672
+ }
673
+ return false;
674
+ }
675
+
676
+ /** How far past its box a widget's focus ring is drawn, and then some. */
677
+ const RING_REACH = 8;
678
+
593
679
  /** A field's `maxlength`, which the widget enforces as it is typed into. */
594
680
  function maxLength(el: Element): number | undefined {
595
681
  const raw = attr(el, 'maxlength')?.trim();