@react-x11/components 0.16.0 → 0.16.1

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.
@@ -562,6 +562,40 @@ export type ReplacedKind =
562
562
  /** An inline `<svg>`: a drawing, sized by what it says of its size. */
563
563
  | 'svg';
564
564
 
565
+ /**
566
+ * Where a control sits on the line, as Chrome sets it: a field, a select
567
+ * or an input button on the baseline of the text it shows, its line in its
568
+ * face centred in its content box — that face's ascent and descent — and a
569
+ * checkbox, a radio button or a range on its border box's bottom, its
570
+ * bottom margin under the line. A `<textarea>` is none of these, and sits
571
+ * on its bottom margin edge as a box that scrolls does.
572
+ */
573
+ export const CONTROL_BASELINES = new WeakMap<
574
+ Box,
575
+ InlineDecoration | 'border'
576
+ >();
577
+
578
+ function controlBaseline(
579
+ el: Element,
580
+ kind: ReplacedKind,
581
+ style: ComputedStyle,
582
+ options: BuildOptions,
583
+ ): InlineDecoration | 'border' | null {
584
+ if (kind === 'checkbox' || kind === 'radio') return 'border';
585
+ if (kind === 'input' && (attr(el, 'type') ?? '').toLowerCase() === 'range') {
586
+ return 'border';
587
+ }
588
+ if (kind !== 'input' && kind !== 'select' && kind !== 'button') return null;
589
+ // a list box shows rows, and sits on its bottom edge
590
+ if (kind === 'select' && Number(attr(el, 'size') ?? 1) > 1) return null;
591
+ return (
592
+ options.faceExtent?.(style) ?? {
593
+ ascent: style.fontSize * 0.8,
594
+ descent: style.fontSize * 0.2,
595
+ }
596
+ );
597
+ }
598
+
565
599
  /** A replaced box's intrinsic dimensions, in device pixels. */
566
600
  export interface Intrinsic {
567
601
  /** Its size — or, on an axis `missing` names, the default object size of
@@ -853,6 +887,10 @@ export interface BuildOptions {
853
887
  * pixels: what a list marker's image with no size of its own is sized
854
888
  * by (`_markerImageSize`). Where it is not known, 0.8em. */
855
889
  faceAscent?(style: ComputedStyle): number | undefined;
890
+ /** How far a style's first face reaches above and below its baseline,
891
+ * in device pixels: where a control sets its text, which is where its
892
+ * baseline is (`CONTROL_BASELINES`). */
893
+ faceExtent?(style: ComputedStyle): InlineDecoration | undefined;
856
894
  /** The size a real widget wants, so the box in the flow is the size the
857
895
  * control will be drawn at. */
858
896
  controlSize(
@@ -1486,6 +1524,8 @@ class Builder {
1486
1524
  ratio: 0,
1487
1525
  };
1488
1526
  this._controls.push(box);
1527
+ const baseline = controlBaseline(el, replaced, style, this._options);
1528
+ if (baseline) CONTROL_BASELINES.set(box, baseline);
1489
1529
  // A control's value is the widget's, not the document's: putting it in
1490
1530
  // the selection index would make Ctrl+A copy the contents of every text
1491
1531
  // field, which no document viewer does.
@@ -252,6 +252,14 @@ export function layoutFlex(
252
252
  const direction =
253
253
  box.style.direction === 'rtl' ? Y.DIRECTION_RTL : Y.DIRECTION_LTR;
254
254
  const row = box.style.flexDirection.startsWith('row');
255
+ // Yoga aligns the lines of a box that wraps, or of a row that aligns by
256
+ // baselines, in one pass of its own, which drops a margin
257
+ // (`lineMarginFix`) — and not those of a column that does not wrap,
258
+ // whatever it aligns by, whose margins Yoga keeps
259
+ let baselines =
260
+ row &&
261
+ (box.style.alignItems === 'baseline' ||
262
+ items.some(({ box: child }) => child.style.alignSelf === 'baseline'));
255
263
  // An `auto` margin across a row that wraps takes the room its line has
256
264
  // past the item (CSS Flexbox 8.1, 9.6), where Yoga's pass that sets the
257
265
  // items across their lines reads an item's alignment and not its
@@ -264,6 +272,37 @@ export function layoutFlex(
264
272
  const aligned = autoAcross(child.style, reverse);
265
273
  if (aligned) node.setAlignSelf(ALIGN[aligned]);
266
274
  }
275
+ } else if (baselines) {
276
+ // Yoga takes a row that does not wrap through that pass as well, and
277
+ // sets its line there as tall as its items and not the row: an item
278
+ // centred or at the end of a row of a height was centred or ended in
279
+ // the items' height. It has sized that line already, as the most any
280
+ // item reaches down to its bottom edge — a baseline, to Yoga — and the
281
+ // largest bottom margin besides, whichever items those are: a row of
282
+ // no height of its own was as tall as both. And it sets an item with
283
+ // an `auto` margin by its baseline: at the top, where the margin puts
284
+ // it at the bottom, and every item beside it as far down as its height
285
+ // reached. That item takes no part in aligning the line by baselines
286
+ // (9.4 step 8), and Yoga is told the alignment its margins come to.
287
+ // With fewer than two items left that do take part, they come to their
288
+ // line's start (8.3), which Yoga is told as well: then nothing in the
289
+ // row is aligned by its baseline, Yoga takes no such pass, and the line
290
+ // is the row. Two or more are lined up by their baselines once those
291
+ // are known (`baselineLines`), and the row laid out again with none.
292
+ for (const { box: child, node } of items) {
293
+ const aligned = autoAcross(child.style, false);
294
+ if (aligned) node.setAlignSelf(ALIGN[aligned]);
295
+ }
296
+ const aligned = items.filter(({ box: child }) =>
297
+ byBaseline(box.style, child.style),
298
+ );
299
+ if (aligned.length < 2) {
300
+ for (const { node } of aligned) node.setAlignSelf(Y.ALIGN_FLEX_START);
301
+ if (box.style.alignItems === 'baseline') {
302
+ root.setAlignItems(Y.ALIGN_FLEX_START);
303
+ }
304
+ baselines = false;
305
+ }
267
306
  }
268
307
  // frozen at its least size on the one line of a box that does not
269
308
  // wrap, by a basis where Yoga takes one along the main axis
@@ -288,12 +327,13 @@ export function layoutFlex(
288
327
  // `align-content` spaces out come out of its layout as tall as their
289
328
  // line and the room after it as well (`unspace`): laid out so that each
290
329
  // is as tall as its line, where the box has room to space them out in —
291
- // a height, or a minimum one
330
+ // a height, or a minimum one — and with the lines at its start where it
331
+ // may be too short for them, a height or a maximum one
292
332
  const spaced =
293
333
  row &&
294
334
  bounded &&
295
335
  !hold.frozen &&
296
- (height !== null || floor > 0) &&
336
+ (height !== null || floor > 0 || ceiling < Infinity) &&
297
337
  spacesLines(box.style);
298
338
  const ask =
299
339
  restretched.length > 0
@@ -409,18 +449,15 @@ export function layoutFlex(
409
449
  }
410
450
  }
411
451
 
412
- // Yoga aligns the lines of a box that wraps, or aligns by baselines, in
413
- // one pass of its own, which drops a margin (`lineMarginFix`)
414
- const baselines =
415
- box.style.alignItems === 'baseline' ||
416
- items.some(({ box: child }) => child.style.alignSelf === 'baseline');
452
+ // whether Yoga's last layout took its pass for lines (`placeItems`)
453
+ let lined = box.style.flexWrap !== 'nowrap' || baselines;
417
454
  let bottom = placeItems(
418
455
  box,
419
456
  ctx,
420
457
  items,
421
458
  row,
422
459
  own,
423
- box.style.flexWrap !== 'nowrap' || baselines,
460
+ lined,
424
461
  contentWidth,
425
462
  undefined,
426
463
  crossed ? crossLines(box.style, items, contentWidth, columnGap) : undefined,
@@ -430,11 +467,11 @@ export function layoutFlex(
430
467
  // by the flex layout again, set at their lines' starts with the margins
431
468
  // that put their baselines together, which makes each line as tall as
432
469
  // that does
433
- const lines = row && baselines ? baselineLines(box.style, items) : null;
470
+ const lines = baselines ? baselineLines(box.style, items) : null;
471
+ const started = new Set<Box>();
434
472
  if (lines) {
435
473
  // every one of them, and the box's own `baseline` too, which is what
436
474
  // takes Yoga down that pass
437
- const started = new Set<Box>();
438
475
  for (const line of lines) {
439
476
  line.forEach(({ box: child, node }, i) => {
440
477
  node.setAlignSelf(Y.ALIGN_FLEX_START);
@@ -445,6 +482,8 @@ export function layoutFlex(
445
482
  if (box.style.alignItems === 'baseline') {
446
483
  root.setAlignItems(Y.ALIGN_FLEX_START);
447
484
  }
485
+ // so it takes it only where the row wraps
486
+ lined = box.style.flexWrap !== 'nowrap';
448
487
  ctx.flexDepth = depth + 1;
449
488
  try {
450
489
  calculate();
@@ -457,7 +496,7 @@ export function layoutFlex(
457
496
  items,
458
497
  row,
459
498
  own,
460
- box.style.flexWrap !== 'nowrap',
499
+ lined,
461
500
  contentWidth,
462
501
  started,
463
502
  );
@@ -485,8 +524,9 @@ export function layoutFlex(
485
524
  items,
486
525
  row,
487
526
  own,
488
- box.style.flexWrap !== 'nowrap' || baselines,
527
+ lined,
489
528
  contentWidth,
529
+ started,
490
530
  );
491
531
  }
492
532
 
@@ -680,7 +720,7 @@ function placeItems(
680
720
  const placed = crossed?.get(child);
681
721
  const across =
682
722
  lined && !placed
683
- ? lineMarginFix(box.style, child, node, row, started?.has(child))
723
+ ? lineMarginFix(box.style, child, node, row, own, started?.has(child))
684
724
  : 0;
685
725
  const itemHeight = row ? heightOf(node, laid) : laid.main;
686
726
  const left =
@@ -896,6 +936,14 @@ function applyItem(
896
936
  if (style.flexShrink) node.setFlexShrink(style.flexShrink);
897
937
  if (style.alignSelf !== AUTO)
898
938
  node.setAlignSelf(ALIGN[style.alignSelf] ?? Y.ALIGN_AUTO);
939
+ // `stretch` sets an item whose height is a percentage of none at its
940
+ // line's start, as tall as its content (`heightOfNone`), where Yoga,
941
+ // which is handed no height for it, stretched it across the line
942
+ if (row && box.parent && heightOfNone(style, own)) {
943
+ const align =
944
+ style.alignSelf === AUTO ? box.parent.style.alignItems : style.alignSelf;
945
+ if (align === 'stretch') node.setAlignSelf(Y.ALIGN_FLEX_START);
946
+ }
899
947
 
900
948
  // Yoga's sizes are border boxes, as `box-sizing: border-box` has them;
901
949
  // a `content-box` length is the content's, and the item's padding and
@@ -2858,22 +2906,26 @@ function baselineLines<T extends { box: Box }>(
2858
2906
  line = [];
2859
2907
  }
2860
2908
  last = box.x;
2861
- const own = box.style;
2862
- const aligned = own.alignSelf === AUTO ? style.alignItems : own.alignSelf;
2863
- // an `auto` margin across the line takes it over alignment
2864
- if (
2865
- aligned === 'baseline' &&
2866
- own.marginTop !== AUTO &&
2867
- own.marginBottom !== AUTO
2868
- ) {
2869
- line.push(item);
2870
- }
2909
+ if (byBaseline(style, box.style)) line.push(item);
2871
2910
  }
2872
2911
  close();
2873
2912
  // one alone on its line is at its start already
2874
2913
  return several ? out : null;
2875
2914
  }
2876
2915
 
2916
+ /** Whether an item takes part in aligning its line by baselines (CSS
2917
+ * Flexbox 8.3, 9.4 step 8): aligned by its baseline, with no `auto`
2918
+ * margin across the line, which takes it over its alignment. */
2919
+ function byBaseline(container: ComputedStyle, item: ComputedStyle): boolean {
2920
+ const aligned =
2921
+ item.alignSelf === AUTO ? container.alignItems : item.alignSelf;
2922
+ return (
2923
+ aligned === 'baseline' &&
2924
+ item.marginTop !== AUTO &&
2925
+ item.marginBottom !== AUTO
2926
+ );
2927
+ }
2928
+
2877
2929
  /**
2878
2930
  * Whether Yoga made a line of a row that wraps the row's height, where its
2879
2931
  * items make it another (CSS Flexbox 9.4, steps 7 and 8; `layoutFlex`).
@@ -2999,13 +3051,16 @@ function autoAcross(
2999
3051
  * top one; and one centred, half the difference. A column's, whose cross
3000
3052
  * axis runs right to left, or that wraps in reverse, is left as Yoga has
3001
3053
  * it, and so is an item an `auto` margin aligns in a row that does not
3002
- * wrap.
3054
+ * wrap: where Yoga still takes that pass for such a row, its items are
3055
+ * laid out again without it, aligned by their baselines (`layoutFlex`).
3003
3056
  */
3004
3057
  function lineMarginFix(
3005
3058
  container: ComputedStyle,
3006
3059
  item: Box,
3007
3060
  node: YogaNode,
3008
3061
  row: boolean,
3062
+ /** Whether the flex box has a definite height. */
3063
+ own: boolean,
3009
3064
  started = false,
3010
3065
  ): number {
3011
3066
  const reverse = container.flexWrap === 'wrap-reverse';
@@ -3024,6 +3079,11 @@ function lineMarginFix(
3024
3079
  : null;
3025
3080
  } else {
3026
3081
  align = style.alignSelf === AUTO ? container.alignItems : style.alignSelf;
3082
+ // which sets one whose height is a percentage of none at its line's
3083
+ // start, as Yoga is told it does (`applyItem`)
3084
+ if (align === 'stretch' && row && heightOfNone(style, own)) {
3085
+ align = 'flex-start';
3086
+ }
3027
3087
  }
3028
3088
  const leading = node.getComputedMargin(row ? Y.EDGE_TOP : Y.EDGE_LEFT);
3029
3089
  const trailing = node.getComputedMargin(row ? Y.EDGE_BOTTOM : Y.EDGE_RIGHT);
@@ -3041,11 +3101,14 @@ function lineMarginFix(
3041
3101
 
3042
3102
  /** An item's first baseline, down from its border edge: its content's
3043
3103
  * first line's, or where it has none, or is replaced, one synthesized
3044
- * from its border box — its bottom edge. */
3104
+ * from its border box — its bottom edge. Past that edge where the line
3105
+ * is: only a scroll container's is held to its box (`firstBaselineIn`),
3106
+ * and an item shorter than its text, or whose padding puts the text
3107
+ * under it, sits on its text where it had sat on its bottom. */
3045
3108
  function itemBaseline(item: Box): number {
3046
3109
  if (item.kind !== 'replaced') {
3047
3110
  const found = firstBaselineIn(item);
3048
- if (found !== null) return Math.min(found - item.y, item.height);
3111
+ if (found !== null) return found - item.y;
3049
3112
  }
3050
3113
  return item.height;
3051
3114
  }
@@ -3197,6 +3260,16 @@ function spacesLines(style: ComputedStyle): boolean {
3197
3260
  * makes too, and is kept. Where they do, Yoga is asked again as the row
3198
3261
  * is, for where the lines and the items are, and the heights are kept from
3199
3262
  * the first layout.
3263
+ *
3264
+ * And where the lines are taller than the row, `space-around` and
3265
+ * `space-evenly` fall back to `safe center`, which sets them at the row's
3266
+ * own start, its top (CSS Box Alignment 3, 4.3 and 5.1; CSS Flexbox 8.4).
3267
+ * Yoga falls back to `flex-start` for both, which is the bottom where the
3268
+ * lines run bottom to top, and ran them out past the row's top. It sets
3269
+ * the lines of a row that wraps in reverse top to bottom and turns them
3270
+ * over once they are set, so it is asked for them at `flex-end` there,
3271
+ * which comes out at the top — where the lines it set at their start, or
3272
+ * spaced, reach past the row's bottom.
3200
3273
  */
3201
3274
  function unspace(
3202
3275
  root: YogaNode,
@@ -3206,6 +3279,16 @@ function unspace(
3206
3279
  layout: () => void,
3207
3280
  ): void {
3208
3281
  for (const { laid } of items) laid.unstretched = NaN;
3282
+ const lines = root.getAlignContent();
3283
+ // where the lines start at the row's bottom, its start is their end
3284
+ const safe =
3285
+ reverse &&
3286
+ (lines === Y.ALIGN_SPACE_AROUND || lines === Y.ALIGN_SPACE_EVENLY);
3287
+ const atEnd = (): void => {
3288
+ root.setAlignContent(Y.ALIGN_FLEX_END);
3289
+ layout();
3290
+ root.setAlignContent(lines);
3291
+ };
3209
3292
  // each item Yoga stretches: `stretch`, with no height of its own
3210
3293
  const align = root.getAlignItems();
3211
3294
  const stretched = items.filter(({ node }) => {
@@ -3216,15 +3299,24 @@ function unspace(
3216
3299
  });
3217
3300
  if (stretched.length === 0) {
3218
3301
  layout();
3302
+ // spaced where there is room, and else at their start, past the bottom
3303
+ if (
3304
+ safe &&
3305
+ linesReach(root, items, reverse) - root.getComputedHeight() > NEAR
3306
+ ) {
3307
+ atEnd();
3308
+ }
3219
3309
  return;
3220
3310
  }
3221
- const lines = root.getAlignContent();
3222
3311
  root.setAlignContent(Y.ALIGN_FLEX_START);
3223
3312
  layout();
3224
3313
  root.setAlignContent(lines);
3225
- if (!(root.getComputedHeight() - linesReach(root, items, reverse) > NEAR)) {
3314
+ const room = root.getComputedHeight() - linesReach(root, items, reverse);
3315
+ if (safe && room < -NEAR) {
3316
+ atEnd();
3226
3317
  return;
3227
3318
  }
3319
+ if (!(room > NEAR)) return;
3228
3320
  const heights = stretched.map(({ node }) => node.getComputedHeight());
3229
3321
  layout();
3230
3322
  stretched.forEach(({ node, laid }, i) => {
@@ -3276,6 +3368,22 @@ function linesReach(
3276
3368
  return reach;
3277
3369
  }
3278
3370
 
3371
+ /**
3372
+ * Whether a row item's height is a percentage of a height its flex box has
3373
+ * none of. That is `auto` to the item's size (CSS 2.1 10.5), and is not
3374
+ * handed to Yoga (`applyItem`), but it is not `auto` to its alignment:
3375
+ * `stretch` stretches an item whose height computes to `auto` (CSS Flexbox
3376
+ * 8.3), and this one's computes to the percentage. Such an item is as tall
3377
+ * as its content, set at its line's start, which is what Yoga is told.
3378
+ */
3379
+ function heightOfNone(
3380
+ style: ComputedStyle,
3381
+ /** Whether the flex box has a definite height. */
3382
+ own: boolean,
3383
+ ): boolean {
3384
+ return !own && isPct(style.height);
3385
+ }
3386
+
3279
3387
  /** Whether an item is stretched across its line: `stretch`, its own or its
3280
3388
  * container's, with no height of its own and no `auto` margin across. */
3281
3389
  function stretches(
@@ -3331,9 +3439,10 @@ interface Across {
3331
3439
  * where they are short of the box; `center` and `flex-end` whether they are
3332
3440
  * or not, so that lines wider than the box run past both its sides, or its
3333
3441
  * start; and the spacing values only where there is room, setting the lines
3334
- * at the box's start where there is none (CSS Box Alignment 3, 4.3:
3335
- * `space-between` falls back to `flex-start`, `space-around` and
3336
- * `space-evenly` to `safe center`, the start where the lines overflow). An
3442
+ * at their start where there is none, or at the box's (CSS Box Alignment 3,
3443
+ * 4.3: `space-between` falls back to `flex-start`, `space-around` and
3444
+ * `space-evenly` to `safe center`, the box's own start where the lines
3445
+ * overflow, and not theirs where they wrap in reverse). An
3337
3446
  * item with an `auto` margin across its line takes the room the line has
3338
3447
  * past it on that side (8.1), and none where there is none; a stretched
3339
3448
  * item is as wide as its line less its margins, within its limits (9.4,
@@ -3381,6 +3490,10 @@ function crossLines(
3381
3490
  for (const size of across) room -= size;
3382
3491
  let at = 0;
3383
3492
  let between = gap;
3493
+ // Where they are wider than the box, `space-around` and `space-evenly`
3494
+ // set the lines at the box's own start (`safe center`), which is where
3495
+ // their last one ends where they wrap in reverse: they run past its end
3496
+ const reverse = style.flexWrap === 'wrap-reverse';
3384
3497
  switch (crossContent(style)) {
3385
3498
  case 'flex-end':
3386
3499
  at = room;
@@ -3398,13 +3511,13 @@ function crossLines(
3398
3511
  if (room > 0) {
3399
3512
  at = room / count / 2;
3400
3513
  between += room / count;
3401
- }
3514
+ } else if (reverse) at = room;
3402
3515
  break;
3403
3516
  case 'space-evenly':
3404
3517
  if (room > 0) {
3405
3518
  at = room / (count + 1);
3406
3519
  between += room / (count + 1);
3407
- }
3520
+ } else if (reverse) at = room;
3408
3521
  break;
3409
3522
  }
3410
3523
  const placed = new Map<Box, Across>();
@@ -45,6 +45,7 @@ import { inkColor, isTransparent, resolve } from '../css/values.js';
45
45
  import { tagOf } from '../dom.js';
46
46
  import {
47
47
  BOX_RAISES,
48
+ CONTROL_BASELINES,
48
49
  GRID_TRACKS,
49
50
  LINE_BOX_RAISES,
50
51
  PADDED_FACES,
@@ -3329,6 +3330,22 @@ function atomicRaise(
3329
3330
  */
3330
3331
  function atomicBaseline(box: Box): number {
3331
3332
  const margin = box.height + box.marginTop + box.marginBottom;
3333
+ // a control on its text's baseline, or on its border box's bottom,
3334
+ // where layout containment does not keep its baseline in
3335
+ const control =
3336
+ box.style.contain & CONTAIN_LAYOUT ? undefined : CONTROL_BASELINES.get(box);
3337
+ if (control === 'border') return box.marginTop + box.height;
3338
+ if (control) {
3339
+ const content = box.height - box.verticalExtra;
3340
+ const text = control.ascent + control.descent;
3341
+ return (
3342
+ box.marginTop +
3343
+ box.borderTop +
3344
+ box.padTop +
3345
+ (content - text) / 2 +
3346
+ control.ascent
3347
+ );
3348
+ }
3332
3349
  const button = isButton(box);
3333
3350
  // A `<button>` with no line in it sits on the bottom of its content box,
3334
3351
  // not of its margin box: where its label would have sat. No specification
@@ -3468,14 +3485,14 @@ function tableBaseline(table: Box): number | null {
3468
3485
  * chevron, and the line it was on came out shorter than a browser's.
3469
3486
  */
3470
3487
  function lastBaselineIn(box: Box): number | null {
3471
- if (box.kind === 'flex') return flexBaseline(box);
3488
+ if (box.kind === 'flex') return heldIn(box, flexBaseline(box));
3472
3489
  if (box.lines?.length) {
3473
3490
  const line = box.lines[box.lines.length - 1];
3474
- return line.y + line.baseline;
3491
+ return heldIn(box, line.y + line.baseline);
3475
3492
  }
3476
3493
  for (let i = box.children.length - 1; i >= 0; i -= 1) {
3477
3494
  const found = childBaseline(box.children[i], lastBaselineIn);
3478
- if (found !== null) return found;
3495
+ if (found !== null) return heldIn(box, found);
3479
3496
  }
3480
3497
  return null;
3481
3498
  }
@@ -3483,15 +3500,32 @@ function lastBaselineIn(box: Box): number | null {
3483
3500
  /** The same, from the first line box or child: a table's baseline is its
3484
3501
  * first row's, and so is a table cell's. */
3485
3502
  export function firstBaselineIn(box: Box): number | null {
3486
- if (box.kind === 'flex') return flexBaseline(box);
3487
- if (box.lines?.length) return box.lines[0].y + box.lines[0].baseline;
3503
+ if (box.kind === 'flex') return heldIn(box, flexBaseline(box));
3504
+ if (box.lines?.length) {
3505
+ return heldIn(box, box.lines[0].y + box.lines[0].baseline);
3506
+ }
3488
3507
  for (const child of box.children) {
3489
3508
  const found = childBaseline(child, firstBaselineIn);
3490
- if (found !== null) return found;
3509
+ if (found !== null) return heldIn(box, found);
3491
3510
  }
3492
3511
  return null;
3493
3512
  }
3494
3513
 
3514
+ /**
3515
+ * A box's baseline as its content gives it, held to its border box where
3516
+ * the box is a scroll container (CSS Box Alignment 3, 9.1), at either edge
3517
+ * — and only there: a box whose text overflows it otherwise, or clips it
3518
+ * (`overflow: clip` is no scroll container), has the baseline its text
3519
+ * has, as Chrome has it. Held at every box, so a scroll container deep in
3520
+ * an item, or in an inline block, holds the baseline it gives to its box.
3521
+ * The flex layout held every item to its height, and an item shorter
3522
+ * than its text sat its text's descent below the items beside it.
3523
+ */
3524
+ function heldIn(box: Box, found: number | null): number | null {
3525
+ if (found === null || !scrolls(box.style)) return found;
3526
+ return Math.min(Math.max(found, box.y), box.y + box.height);
3527
+ }
3528
+
3495
3529
  function childBaseline(
3496
3530
  child: Box,
3497
3531
  inside: (box: Box) => number | null,
@@ -3502,9 +3536,9 @@ function childBaseline(
3502
3536
  // a table is passed over for a block's last baseline (`lastBaselineIn`)
3503
3537
  if (inside === lastBaselineIn && child.kind === 'table') return null;
3504
3538
  // the legacy rule is a block container's last baseline alone (CSS Box
3505
- // Alignment 3, 9.2): its first is its first line's, clipped or not —
3506
- // and a button that clips keeps its label's, here as on a line of its
3507
- // own (`atomicBaseline`)
3539
+ // Alignment 3, 9.2): its first is its first line's, clipped or not, held
3540
+ // to its border box (`heldIn`) — and a button that clips keeps its
3541
+ // label's, here as on a line of its own (`atomicBaseline`)
3508
3542
  if (
3509
3543
  inside === lastBaselineIn &&
3510
3544
  child.kind !== 'flex' &&
package/src/html/node.ts CHANGED
@@ -1051,6 +1051,10 @@ export class HtmlViewNode extends Node {
1051
1051
  const fonts = this._fonts();
1052
1052
  return fonts ? faceExtentOf(fonts, style).ascent : undefined;
1053
1053
  },
1054
+ faceExtent: (style) => {
1055
+ const fonts = this._fonts();
1056
+ return fonts ? faceExtentOf(fonts, style) : undefined;
1057
+ },
1054
1058
  controlSize: (el, kind, style) =>
1055
1059
  measureControl(el, kind, style, this._fonts(), look),
1056
1060
  });