@hypen-space/web 0.4.956 → 0.4.980

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.
@@ -12,6 +12,7 @@ import {
12
12
  parseSize,
13
13
  cssLengthToPx,
14
14
  cssLengthToDimension,
15
+ resolveLineHeight,
15
16
  setCurrentViewport,
16
17
  getCurrentViewport,
17
18
  } from "./utils.js";
@@ -179,9 +180,22 @@ function buildTaffyStyle(
179
180
  // `List` is the DSL's vertical-stack iterator (see dom/components/list.ts
180
181
  // — `flex-direction: column` is its default). Match that here so feeds
181
182
  // like Notifications stack their rows vertically instead of flowing
182
- // sideways. The `direction` prop can still flip it to row.
183
- const isListColumn = type === "list" && props.direction !== "horizontal";
184
- const isColumn = !isStack && (type === "column" || isListColumn || props.flexDirection === "column");
183
+ // sideways. The legacy `direction` prop can flip it to row, and so can
184
+ // a Tailwind `flex-row` class which lands on `props.flexDirection`
185
+ // (without this, top-tab strips built as `List.flex-row` stacked
186
+ // vertically because the List default won the tie-break).
187
+ const isListColumn =
188
+ type === "list" &&
189
+ props.direction !== "horizontal" &&
190
+ props.flexDirection !== "row";
191
+ // Button defaults to `flex-direction: column` to match the DOM renderer
192
+ // (see dom/components/button.ts). With Taffy's default `align-items:
193
+ // stretch`, treating Button as a row caused inner `Column { Icon, Text }`
194
+ // children to collapse onto each other (icon overlapping label). Only
195
+ // apply the column default when no explicit `flexDirection` is set so a
196
+ // Tailwind `flex-row` on a button still wins.
197
+ const isButtonColumn = type === "button" && props.flexDirection === undefined;
198
+ const isColumn = !isStack && (type === "column" || isListColumn || isButtonColumn || props.flexDirection === "column");
185
199
  const isGrid = !isStack && (type === "grid" || props.display === "grid");
186
200
 
187
201
  if (isStack) {
@@ -372,7 +386,7 @@ function buildTaffyStyle(
372
386
  // Taffy defaults to `box-sizing: border-box`, so we add the
373
387
  // padding/border ourselves into `size.height`.
374
388
  const fontSize = cssLengthToPx(props.fontSize) ?? 16;
375
- const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.5;
389
+ const lineHeight = resolveLineHeight(props.lineHeight, fontSize) ?? fontSize * 1.5;
376
390
  const minRows = type === "textarea" ? Math.max(1, Number(props.rows) || 3) : 1;
377
391
  const padTop = cssLengthToPx(props.paddingTop ?? props.padding) ?? 0;
378
392
  const padBottom = cssLengthToPx(props.paddingBottom ?? props.padding) ?? 0;
@@ -394,7 +408,30 @@ function buildTaffyStyle(
394
408
  (parent.type.toLowerCase() === "grid" || parent.props.display === "grid");
395
409
  const widthIn = parentIsGridLayout && props.width === "100%" ? undefined : props.width;
396
410
  const heightIn = parentIsGridLayout && props.height === "100%" ? undefined : props.height;
397
- style.size = { width: toDimension(widthIn), height: toDimension(heightIn) };
411
+
412
+ // Mirror the DOM rule `[data-hypen-type="row"]:has(> [data-hypen-flex])
413
+ // { width: 100% }` (and the symmetric column case). When a flex
414
+ // container has no explicit main-axis size and any child opts into
415
+ // weighted growth (`flex` / `flexGrow > 0`), Taffy would otherwise
416
+ // size the container to its min-content — so a Row of `flex-1`
417
+ // segmented-control buttons stayed pill-sized instead of stretching
418
+ // across the parent. Only do this when at least one child explicitly
419
+ // asks for flex, matching the DOM `:has(> [data-hypen-flex])` guard.
420
+ const isRowContainer = !isStack && !isGrid && !isColumn;
421
+ const isColumnContainer = !isStack && !isGrid && isColumn;
422
+ const childAsksFlex = node.children.some((c) => {
423
+ const fg = parseFloat(c.props.flexGrow);
424
+ if (Number.isFinite(fg) && fg > 0) return true;
425
+ const f = parseFloat(c.props.flex);
426
+ return Number.isFinite(f) && f > 0;
427
+ });
428
+ let resolvedWidth = toDimension(widthIn);
429
+ let resolvedHeight = toDimension(heightIn);
430
+ if (childAsksFlex) {
431
+ if (isRowContainer && widthIn === undefined) resolvedWidth = "100%";
432
+ if (isColumnContainer && heightIn === undefined) resolvedHeight = "100%";
433
+ }
434
+ style.size = { width: resolvedWidth, height: resolvedHeight };
398
435
  }
399
436
 
400
437
  // --- Min / Max constraints -------------------------------------------------
@@ -769,12 +806,17 @@ function buildTree(
769
806
  // container with size 0×0, and downstream siblings were placed on top of
770
807
  // each other ("1,431 likes" overlapping the caption row was the visible
771
808
  // symptom).
772
- if (node.type.toLowerCase() === "text" && props[0] && node.children.length === 0) {
773
- const text = String(props[0] || "");
809
+ // `props[0] != null` not `props[0]` truthy. The number `0`, the empty
810
+ // string, and `false` are valid text content; falsy guards here let
811
+ // those cases skip the measurement leaf and inherit a 0-height container,
812
+ // which made siblings stack on top of each other (a bound `0` overlapping
813
+ // the next line was the visible symptom in calorie-counter's hero).
814
+ if (node.type.toLowerCase() === "text" && props[0] != null && node.children.length === 0) {
815
+ const text = String(props[0]);
774
816
  const fontSize = cssLengthToPx(props.fontSize) ?? 16;
775
817
  const fontWeight = props.fontWeight || "normal";
776
818
  const fontFamily = props.fontFamily || "system-ui, sans-serif";
777
- const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
819
+ const lineHeight = resolveLineHeight(props.lineHeight, fontSize) ?? fontSize * 1.2;
778
820
 
779
821
  const p = parseSpacing(props.padding || 0);
780
822
  if (props.paddingTop !== undefined) p.top = cssLengthToPx(props.paddingTop) ?? 0;
@@ -1201,12 +1243,13 @@ function computeLayoutFallback(
1201
1243
  if (height === null) height = size;
1202
1244
  }
1203
1245
 
1204
- if (node.type.toLowerCase() === "text" && node.props[0]) {
1205
- const text = String(node.props[0] || "");
1246
+ // Same `!= null` rule as above — `0` / `""` / `false` are valid text.
1247
+ if (node.type.toLowerCase() === "text" && node.props[0] != null) {
1248
+ const text = String(node.props[0]);
1206
1249
  const fontSize = cssLengthToPx(props.fontSize) ?? 16;
1207
1250
  const fontWeight = props.fontWeight || "normal";
1208
1251
  const fontFamily = props.fontFamily || "system-ui, sans-serif";
1209
- const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
1252
+ const lineHeight = resolveLineHeight(props.lineHeight, fontSize) ?? fontSize * 1.2;
1210
1253
 
1211
1254
  const maxWidth = width || availableAfterMargin.width - padding.left - padding.right;
1212
1255
  const maxLinesRaw = props.maxLines;
@@ -9,7 +9,7 @@ import { renderText } from "./text.js";
9
9
  import { ScrollManager, isScrollable } from "./scroll.js";
10
10
  import { getVisibleChildren, VIRTUALIZE_THRESHOLD } from "./virtualize.js";
11
11
  import type { SelectionManager } from "./selection.js";
12
- import { cssLengthToPx } from "./utils.js";
12
+ import { cssLengthToPx, resolveLineHeight } from "./utils.js";
13
13
 
14
14
  /**
15
15
  * Module-level reference to the active SelectionManager so paintText
@@ -271,13 +271,14 @@ function paintText(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
271
271
  const layout = node.layout!;
272
272
  const props = node.props;
273
273
 
274
- let text = String(props[0] || props.text || "");
274
+ // `??` (not `||`) so a bound `0`, `false`, or `""` still renders.
275
+ let text = String(props[0] ?? props.text ?? "");
275
276
  const color = props.color || "#000000";
276
277
  const fontSize = cssLengthToPx(props.fontSize) ?? 16;
277
278
  const fontWeight = props.fontWeight || "normal";
278
279
  const fontFamily = props.fontFamily || "system-ui, sans-serif";
279
280
  const textAlign = props.textAlign || "left";
280
- const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
281
+ const lineHeight = resolveLineHeight(props.lineHeight, fontSize) ?? fontSize * 1.2;
281
282
  const textDecoration = props.textDecoration || "none";
282
283
  const textTransform = props.textTransform || "none";
283
284
  const letterSpacing = cssLengthToPx(props.letterSpacing) ?? 0;
@@ -474,7 +475,7 @@ function paintInput(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
474
475
  const fontSize = cssLengthToPx(props.fontSize) ?? 16;
475
476
  const fontWeight = props.fontWeight || "normal";
476
477
  const fontFamily = props.fontFamily || "system-ui, sans-serif";
477
- const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
478
+ const lineHeight = resolveLineHeight(props.lineHeight, fontSize) ?? fontSize * 1.2;
478
479
 
479
480
  renderText(
480
481
  ctx,
@@ -1332,8 +1333,8 @@ function paintBadge(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
1332
1333
  drawRoundedRect(ctx, x, y, width, height, radius);
1333
1334
  ctx.fill();
1334
1335
 
1335
- // Text content
1336
- const text = String(props[0] || props.text || "");
1336
+ // `??` so a literal `0`/`false`/`""` value still renders.
1337
+ const text = String(props[0] ?? props.text ?? "");
1337
1338
  if (text) {
1338
1339
  ctx.fillStyle = props.color || "#ffffff";
1339
1340
  ctx.font = `${props.fontWeight || "bold"} ${props.fontSize || 10}px ${props.fontFamily || "sans-serif"}`;
@@ -1368,8 +1369,8 @@ function paintAvatar(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
1368
1369
  ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
1369
1370
  ctx.fill();
1370
1371
 
1371
- // Text initials if provided
1372
- const text = String(props[0] || props.text || props.initials || "");
1372
+ // `??` so a literal `0`/`false`/`""` value still renders.
1373
+ const text = String(props[0] ?? props.text ?? props.initials ?? "");
1373
1374
  if (text) {
1374
1375
  ctx.fillStyle = props.color || "#ffffff";
1375
1376
  ctx.font = `${props.fontWeight || "bold"} ${props.fontSize || size / 2.5}px ${props.fontFamily || "sans-serif"}`;
@@ -1509,7 +1510,8 @@ function paintLink(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
1509
1510
  const layout = node.layout!;
1510
1511
  const props = node.props;
1511
1512
 
1512
- const text = String(props[0] || props.text || "");
1513
+ // `??` so a literal `0`/`false`/`""` value still renders.
1514
+ const text = String(props[0] ?? props.text ?? "");
1513
1515
  const color = node.hovered ? (props.hoverColor || "#0056b3") : (props.color || "#007bff");
1514
1516
  const fontSize = cssLengthToPx(props.fontSize) ?? 16;
1515
1517
  const fontWeight = props.fontWeight || "normal";
@@ -191,7 +191,13 @@ export function renderText(
191
191
  ctx.save();
192
192
  ctx.font = font;
193
193
  ctx.fillStyle = style.color;
194
- ctx.textBaseline = "top";
194
+ // Use `middle` baseline so we can position each glyph at the visual centre
195
+ // of its line box. With `top`, canvas draws from the top of the EM square
196
+ // and any extra `line-height − font-size` space lands as padding BELOW the
197
+ // glyph — visible as `+` inside a round FAB drifting toward the top of
198
+ // the circle. CSS centres glyphs within the line box; matching that
199
+ // requires `middle` + half-lineHeight offset.
200
+ ctx.textBaseline = "middle";
195
201
 
196
202
  const metrics = measureText(ctx, text, style, width, maxLines, textOverflow);
197
203
 
@@ -206,7 +212,8 @@ export function renderText(
206
212
  // Render each line
207
213
  for (let i = 0; i < metrics.lines.length; i++) {
208
214
  const line = metrics.lines[i];
209
- const lineY = startY + i * metrics.lineHeight;
215
+ // Centre of the line box (top of line + half of line-height).
216
+ const lineY = startY + i * metrics.lineHeight + metrics.lineHeight / 2;
210
217
 
211
218
  // Calculate X based on text alignment
212
219
  let lineX = x;
@@ -86,6 +86,38 @@ export function cssLengthToPx(value: any): number | null {
86
86
  }
87
87
  }
88
88
 
89
+ /**
90
+ * Resolve a CSS `line-height` value to a pixel value.
91
+ *
92
+ * Unitless numbers (e.g. `1`, `1.5`, `"1.25"`) are CSS multipliers of the
93
+ * font-size — `line-height: 1` (Tailwind's `leading-none`) means line box =
94
+ * 1 × font-size, NOT 1 pixel. Without this branch, `cssLengthToPx("1")`
95
+ * returned literally `1`, and a Column of `leading-none` Texts collapsed
96
+ * each line to 1px tall — emoji + label in a bottom-tab button rendered at
97
+ * the same y and overlapped.
98
+ *
99
+ * Anything with an explicit unit (`16px`, `1.5em`, `1rem`, `120%` is
100
+ * rejected → null since Taffy/measure expects px) falls through to
101
+ * `cssLengthToPx`.
102
+ */
103
+ export function resolveLineHeight(value: any, fontSize: number): number | null {
104
+ if (value === undefined || value === null) return null;
105
+ if (typeof value === "number" && Number.isFinite(value)) {
106
+ // Unitless number: multiplier of font-size (matches CSS spec).
107
+ return value * fontSize;
108
+ }
109
+ if (typeof value === "string") {
110
+ const s = value.trim();
111
+ if (s === "" || s === "auto" || s === "normal") return null;
112
+ // Pure numeric string with no unit → unitless multiplier.
113
+ if (/^-?\d*\.?\d+$/.test(s)) {
114
+ const n = parseFloat(s);
115
+ return Number.isFinite(n) ? n * fontSize : null;
116
+ }
117
+ }
118
+ return cssLengthToPx(value);
119
+ }
120
+
89
121
  /**
90
122
  * Parse a CSS length string for Taffy's Dimension type. Keeps `%` values as
91
123
  * the tagged percentage string, turns `"auto"`/empty/invalid into `"auto"`,
@@ -35,7 +35,8 @@ export const badgeHandler: ComponentHandler = {
35
35
  }
36
36
 
37
37
  // Text content
38
- const text = props["0"] || props.text;
38
+ // `??` (not `||`) so the literal `0`, `false`, `""` still render.
39
+ const text = props["0"] ?? props.text;
39
40
  if (text !== undefined) {
40
41
  el.textContent = String(text);
41
42
  }
@@ -32,7 +32,8 @@ export const headingHandler: ComponentHandler = {
32
32
  }
33
33
 
34
34
  // Text content
35
- const text = props["0"] || props.text;
35
+ // `??` (not `||`) so the literal `0`, `false`, `""` still render.
36
+ const text = props["0"] ?? props.text;
36
37
  if (text !== undefined) {
37
38
  el.textContent = String(text);
38
39
  }
@@ -13,7 +13,8 @@ export const paragraphHandler: ComponentHandler = {
13
13
 
14
14
  applyProps(el: HTMLElement, props: Record<string, any>): void {
15
15
  // Text content
16
- const text = props["0"] || props.text;
16
+ // `??` (not `||`) so the literal `0`, `false`, `""` still render.
17
+ const text = props["0"] ?? props.text;
17
18
  if (text !== undefined) {
18
19
  el.textContent = String(text);
19
20
  }
@@ -20,8 +20,11 @@ export const textHandler: ComponentHandler = {
20
20
  },
21
21
 
22
22
  applyProps(el: HTMLElement, props: Record<string, any>): void {
23
- // Text content from first positional arg or "text" prop
24
- const text = props["0"] || props.text;
23
+ // Text content from first positional arg or "text" prop. Use `??` (not
24
+ // `||`) so the literal number `0`, `false`, and the empty string still
25
+ // render — `||` falls through on falsy values and a bound `0` was
26
+ // disappearing entirely on web while iOS rendered it correctly.
27
+ const text = props["0"] ?? props.text;
25
28
  if (text !== undefined) {
26
29
  // Store the original text template for state interpolation
27
30
  el.dataset.textTemplate = String(text);