@swiftbrowser/web 0.3.0 → 0.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/web",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "SwiftBrowser web renderer and dev server: applies render ops from a Swift/Wasm app to the DOM inside an iPhone frame.",
6
6
  "repository": {
@@ -151,6 +151,10 @@ const LIST_CELL_PADDING = 16;
151
151
  // is a sentence-case headline-sized line that starts 9 into the 35 gap
152
152
  // between cards and ends 10 above its card.
153
153
  const LIST_ROW_VPAD = 13;
154
+ // A row holding just a toggle or a stepper has 10 above and below it (so
155
+ // both come out 52 tall); a segmented picker has 14 (60 tall).
156
+ const LIST_ROW_CONTROL_VPAD = 10;
157
+ const LIST_ROW_SEGMENTED_VPAD = 14;
154
158
  const LIST_ROW_MIN_HEIGHT = 52;
155
159
  const LIST_TOP = 18;
156
160
  const LIST_BOTTOM = 20;
@@ -158,6 +162,8 @@ const LIST_HEADER_LIFT = 9;
158
162
  const LIST_HEADER_GAP = 10;
159
163
  const LIST_FOOTER_GAP = 6;
160
164
  const LIST_SECTION_GAP = 35;
165
+ // Below a section's footer the gap to the next section is 12 shorter.
166
+ const LIST_SECTION_GAP_AFTER_FOOTER = 23;
161
167
  const LIST_CHEVRON_WIDTH = 20;
162
168
  // Phase 4.
163
169
  // iOS 26: the tab bar is a 62-tall glass capsule floating 21 from the sides
@@ -460,6 +466,19 @@ function cellStyle(node: LayoutNode, key: string): unknown {
460
466
  return undefined;
461
467
  }
462
468
 
469
+ /** The default vertical padding above and below a list row's content. */
470
+ function listRowVPad(row: LayoutNode): number {
471
+ switch (row.kind) {
472
+ case 'picker':
473
+ return str(row.props['style']) === 'segmented' ? LIST_ROW_SEGMENTED_VPAD : 0;
474
+ case 'toggle':
475
+ case 'stepper':
476
+ return LIST_ROW_CONTROL_VPAD;
477
+ default:
478
+ return LIST_ROW_VPAD;
479
+ }
480
+ }
481
+
463
482
  /** `gridCellColumns` of a grid cell (at least 1). */
464
483
  function gridSpanOf(node: LayoutNode): number {
465
484
  const span = num(cellStyle(node, 'gridCellColumns'));
@@ -472,6 +491,20 @@ function gridAnchorOf(node: LayoutNode): [number, number] | null {
472
491
  return point ? [point.x, point.y] : null;
473
492
  }
474
493
 
494
+ /** `gridColumnAlignment` of a grid cell as a horizontal unit anchor. */
495
+ function gridColumnAnchorOf(node: LayoutNode): number | null {
496
+ switch (str(cellStyle(node, 'gridColumnAlignment'))) {
497
+ case 'leading':
498
+ return 0;
499
+ case 'center':
500
+ return 0.5;
501
+ case 'trailing':
502
+ return 1;
503
+ default:
504
+ return null;
505
+ }
506
+ }
507
+
475
508
  /** Vertical anchor of a `gridrow` alignment (null → the grid's). */
476
509
  function rowAnchor(alignment: string | null, gridAlignment: string): number {
477
510
  switch (alignment) {
@@ -892,6 +925,8 @@ interface GridPlan {
892
925
  size: Size;
893
926
  columns: number[];
894
927
  columnX: number[];
928
+ /** Horizontal unit anchor per column from a cell's `gridColumnAlignment` (null → the grid's). */
929
+ columnAnchors: (number | null)[];
895
930
  rows: GridRowPlan[];
896
931
  }
897
932
 
@@ -1310,8 +1345,15 @@ export class LayoutPass {
1310
1345
  height: style === 'plain' ? TEXTFIELD_HEIGHT_PLAIN : TEXTFIELD_HEIGHT_ROUNDED,
1311
1346
  };
1312
1347
  }
1313
- case 'color':
1314
1348
  case 'shape':
1349
+ if (node.props['shape'] === 'circle') {
1350
+ // SwiftUI's `Circle` is the largest square in the proposal (so in a
1351
+ // stack it gives the leftover width to its siblings).
1352
+ // An open axis counts as SHAPE_DEFAULT, as for the other shapes.
1353
+ return square(Math.min(p.width ?? SHAPE_DEFAULT, p.height ?? SHAPE_DEFAULT));
1354
+ }
1355
+ return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
1356
+ case 'color':
1315
1357
  case 'canvas':
1316
1358
  return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
1317
1359
  case 'video':
@@ -2178,7 +2220,11 @@ export class LayoutPass {
2178
2220
  };
2179
2221
  for (let g = 0; g < groupsSource.length; g++) {
2180
2222
  const source = groupsSource[g] as GroupSource;
2181
- if (g > 0) y += sectionGap;
2223
+ const previous = groups[g - 1];
2224
+ if (previous) {
2225
+ const footed = previous.footer !== null || (previous.footerView !== null && previous.footerView.rect.height > 0);
2226
+ y += plain ? sectionGap : footed ? LIST_SECTION_GAP_AFTER_FOOTER : sectionGap;
2227
+ }
2182
2228
  const top = y;
2183
2229
  let header: ListGroupPlan['header'] = null;
2184
2230
  let footer: ListGroupPlan['footer'] = null;
@@ -2205,13 +2251,12 @@ export class LayoutPass {
2205
2251
  continue;
2206
2252
  }
2207
2253
  // A menu or inline picker already reports its row height (44 for
2208
- // a menu, 44 per option inline); other rows (a segmented picker
2209
- // too) get the 13px vertical padding. Phase 6
2254
+ // a menu, 44 per option inline); a toggle, stepper or segmented
2255
+ // picker has its own vertical padding, other rows 13. Phase 6
2210
2256
  // `listRowInsets` replaces all four insets; a `badge` reserves its
2211
2257
  // width (plus a gap) at the trailing edge.
2212
2258
  const hints = rowHintsOf(rowNode);
2213
- const unwrappedRow = unwrapStyled(rowNode);
2214
- const defaultVPad = unwrappedRow.kind === 'picker' && str(unwrappedRow.props['style']) !== 'segmented' ? 0 : LIST_ROW_VPAD;
2259
+ const defaultVPad = listRowVPad(unwrapStyled(rowNode));
2215
2260
  const insets: EdgeInsets = hints.insets ?? {
2216
2261
  top: defaultVPad,
2217
2262
  leading: LIST_CELL_PADDING,
@@ -3613,8 +3658,12 @@ export class LayoutPass {
3613
3658
  * whose non-spanning cells are all flexible (or that has none) is flexible;
3614
3659
  * the others take the largest ideal width of their non-spanning cells. The
3615
3660
  * flexible columns share what is left of the proposal width equally (their
3616
- * ideal width with a nil proposal). Row heights come from measuring every
3617
- * cell at its cell width; a non-`gridrow` child is one full-span cell.
3661
+ * ideal width with a nil proposal). A non-`gridrow` child is one full-span
3662
+ * cell; one wider than the columns (a `Divider` takes the whole proposal)
3663
+ * widens the grid, every column by the same amount (unless it has
3664
+ * `gridCellUnsizedAxes(.horizontal)`). Row heights come from
3665
+ * measuring every cell at its cell width. A cell's `gridColumnAlignment`
3666
+ * aligns its whole column.
3618
3667
  */
3619
3668
  private gridPlan(node: LayoutNode, ctx: Context, p: Proposal): GridPlan {
3620
3669
  return this.plan(node, p, () => {
@@ -3677,6 +3726,30 @@ export class LayoutPass {
3677
3726
  if (flexible[j]) columns[j] = flexibleCount > 0 ? remaining / flexibleCount : 0;
3678
3727
  }
3679
3728
  }
3729
+ // Full-span children wider than the columns widen every column alike.
3730
+ const fullProposal: Proposal = { width: finite(p.width) ? p.width : null, height: null };
3731
+ let fullWidth = 0;
3732
+ for (const source of sources) {
3733
+ if (!source.full) continue;
3734
+ const cctx = this.gridCellContext(node, null, ctx);
3735
+ for (const cell of source.cells) {
3736
+ if (cellStyle(cell.node, 'gridCellUnsizedAxes') === true) continue;
3737
+ fullWidth = Math.max(fullWidth, this.measure(cell.node, cctx, fullProposal).width);
3738
+ }
3739
+ }
3740
+ const columnsWidth = columns.reduce((sum, w) => sum + w, 0) + hSpacing * (columnCount - 1);
3741
+ if (fullWidth > columnsWidth + EPSILON) {
3742
+ const extra = (fullWidth - columnsWidth) / columnCount;
3743
+ for (let j = 0; j < columnCount; j++) columns[j] = (columns[j] as number) + extra;
3744
+ }
3745
+ const columnAnchors = new Array<number | null>(columnCount).fill(null);
3746
+ for (const source of sources) {
3747
+ if (source.full) continue;
3748
+ for (const cell of source.cells) {
3749
+ const anchor = cell.span === 1 ? gridColumnAnchorOf(cell.node) : null;
3750
+ if (anchor !== null && columnAnchors[cell.column] === null) columnAnchors[cell.column] = anchor;
3751
+ }
3752
+ }
3680
3753
  const columnX = new Array<number>(columnCount).fill(0);
3681
3754
  for (let j = 1; j < columnCount; j++) {
3682
3755
  columnX[j] = (columnX[j - 1] as number) + (columns[j - 1] as number) + hSpacing;
@@ -3713,7 +3786,7 @@ export class LayoutPass {
3713
3786
  y += height + vSpacing;
3714
3787
  }
3715
3788
  const gridHeight = rows.length > 0 ? y - vSpacing : 0;
3716
- return { size: { width: gridWidth, height: gridHeight }, columns, columnX, rows };
3789
+ return { size: { width: gridWidth, height: gridHeight }, columns, columnX, columnAnchors, rows };
3717
3790
  });
3718
3791
  }
3719
3792
 
@@ -3727,7 +3800,8 @@ export class LayoutPass {
3727
3800
  const rowY = rowAnchor(row.alignment, gridAlignment);
3728
3801
  for (const cell of row.cells) {
3729
3802
  const box: Size = { width: cell.width, height: row.height };
3730
- const [ax, ay] = gridAnchorOf(cell.node) ?? [gridX, rowY];
3803
+ const columnX = cell.full ? null : (plan.columnAnchors[cell.column] ?? null);
3804
+ const [ax, ay] = gridAnchorOf(cell.node) ?? [columnX ?? gridX, rowY];
3731
3805
  const dx = safeOffset(box.width, cell.size.width, ax);
3732
3806
  const dy = safeOffset(box.height, cell.size.height, ay);
3733
3807
  const cellX = cell.full ? 0 : (plan.columnX[cell.column] ?? 0);
package/src/styles.css CHANGED
@@ -1026,6 +1026,23 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
1026
1026
  pointer-events: none;
1027
1027
  }
1028
1028
 
1029
+ /* A text field row is the <input> (or <textarea>) itself, which has no
1030
+ ::after: its separator is a 1px background image in the same place. */
1031
+ .sb-section-rows > :is(input, textarea):not(:last-child),
1032
+ .sb-list-content > :is(input, textarea):not(:last-child):not(:has(+ .sb-section)) {
1033
+ background-image: linear-gradient(var(--sb-color-row-separator), var(--sb-color-row-separator));
1034
+ background-repeat: no-repeat;
1035
+ background-position: var(--sb-separator-leading, 16px) 100%;
1036
+ background-size: calc(100% - var(--sb-separator-leading, 16px) - 16px) 1px;
1037
+ }
1038
+
1039
+ .sb-section-rows > :is(input, textarea)[data-sb-separator-bottom="hidden"],
1040
+ .sb-list-content > :is(input, textarea)[data-sb-separator-bottom="hidden"],
1041
+ .sb-section-rows > :is(input, textarea):has(+ [data-sb-separator-top="hidden"]),
1042
+ .sb-list-content > :is(input, textarea):has(+ [data-sb-separator-top="hidden"]) {
1043
+ background-image: none;
1044
+ }
1045
+
1029
1046
  .sb-section-rows > .sb-button,
1030
1047
  .sb-list-content > .sb-button {
1031
1048
  border-radius: 0;