@swiftbrowser/web 0.9.7 → 0.9.9

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.9.7",
3
+ "version": "0.9.9",
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": {
@@ -3357,6 +3357,12 @@ export class LayoutPass {
3357
3357
  const searchOnTop = parts.search !== null && cctx.bottomBar > 0;
3358
3358
  const searchRow = searchOnTop ? SEARCH_TOP_ROW : 0;
3359
3359
  const searchBottom = parts.search && !searchOnTop ? SEARCH_BOTTOM_HEIGHT + searchMargin : 0;
3360
+ // The on-screen keyboard (device mode) covers the screen's bottom. A
3361
+ // bottom search field rises above it, as iOS 26 docks it on the
3362
+ // keyboard, so what is typed into it can be seen; the bars stay put,
3363
+ // covered.
3364
+ const keyboard = reachesBottom ? this.keyboard : 0;
3365
+ const searchLift = searchBottom > 0 ? keyboard : 0;
3360
3366
  const contentTop = barHeight + largeHeight + searchRow;
3361
3367
  const content: Rect = {
3362
3368
  x,
@@ -3375,7 +3381,7 @@ export class LayoutPass {
3375
3381
  const marginH = searchOnTop ? SEARCH_FIELD_MARGIN_H : SEARCH_BOTTOM_MARGIN_H;
3376
3382
  chrome['searchField'] = {
3377
3383
  x: x + marginH,
3378
- y: searchOnTop ? y + barHeight + largeHeight + SEARCH_TOP_ABOVE : y + size.height - searchBottom,
3384
+ y: searchOnTop ? y + barHeight + largeHeight + SEARCH_TOP_ABOVE : y + size.height - searchLift - searchBottom,
3379
3385
  width: Math.max(0, size.width - 2 * marginH),
3380
3386
  height: searchOnTop ? SEARCH_TOP_HEIGHT : SEARCH_BOTTOM_HEIGHT,
3381
3387
  };
@@ -3675,12 +3681,11 @@ export class LayoutPass {
3675
3681
  // Under a tab bar the bar (which includes the bottom safe area) is what
3676
3682
  // non-scrolling content must clear; a bottom toolbar already excluded it.
3677
3683
  const clears = bottomBarHeight === 0 && y + size.height >= screen.height ? Math.max(safeArea.bottom, cctx.bottomBar) : 0;
3678
- // The on-screen keyboard (device mode) covers the screen's bottom, the
3679
- // bars with it: the content stops above it as on iOS (the bars stay
3680
- // put, covered), and a scroll view runs under only the part of a bar
3681
- // still showing above it.
3682
- const keyboard = reachesBottom ? this.keyboard : 0;
3683
- const bottomInset = Math.max(clears, keyboard - bottomCover);
3684
+ // The keyboard covers the bars (but not the search field, lifted above
3685
+ // it): the content stops above it as on iOS, and a scroll view runs under
3686
+ // only the part of a bar still showing above it, or under the whole
3687
+ // search field.
3688
+ const bottomInset = Math.max(clears, keyboard - (searchLift > 0 ? 0 : bottomCover));
3684
3689
  // Phase 8: `.ignoresSafeArea` content grows up under the bar (and the
3685
3690
  // large title / search field), which is transparent over it.
3686
3691
  const contentCtx: Context = {
@@ -3692,7 +3697,7 @@ export class LayoutPass {
3692
3697
  ...(bottomCover > 0 ? { bottomBar: bottomCover, bottomEdge: y + size.height } : {}),
3693
3698
  };
3694
3699
  if (keyboard > 0) {
3695
- contentCtx.bottomBar = Math.max(0, (bottomCover > 0 ? bottomCover : cctx.bottomBar) - keyboard);
3700
+ contentCtx.bottomBar = searchLift > 0 ? bottomCover : Math.max(0, (bottomCover > 0 ? bottomCover : cctx.bottomBar) - keyboard);
3696
3701
  contentCtx.bottomEdge = y + size.height - keyboard;
3697
3702
  }
3698
3703
  if (scroller) {
@@ -3716,7 +3721,7 @@ export class LayoutPass {
3716
3721
  // content clears there.
3717
3722
  if (!large) contentCtx.barAbove = { top: content.y, height: content.y - y, x, width: size.width };
3718
3723
  if (keyboard > 0) {
3719
- contentCtx.bottomBar = Math.max(0, (bottomCover > 0 ? bottomCover + clears : cctx.bottomBar) - keyboard);
3724
+ contentCtx.bottomBar = searchLift > 0 ? bottomCover + clears : Math.max(0, (bottomCover > 0 ? bottomCover + clears : cctx.bottomBar) - keyboard);
3720
3725
  } else if (bottomCover > 0) {
3721
3726
  contentCtx.bottomBar = bottomCover + clears;
3722
3727
  }
package/src/protocol.ts CHANGED
@@ -1869,6 +1869,14 @@ export interface ScrollViewProps {
1869
1869
  * past it while scrolling and snapping as before.
1870
1870
  */
1871
1871
  clipDisabled?: boolean;
1872
+ /**
1873
+ * `.scrollPosition(id:)`: when scrolling settles, the page sends
1874
+ * `scrollposition` to the scroll target (`scrollID`) at the view's anchor
1875
+ * point, once per change of target (docs/ops-protocol.md, "Scrolling to a view").
1876
+ */
1877
+ reportsPosition?: boolean;
1878
+ /** With `reportsPosition`: the point of the visible area the reported target is at; the leading edge (`{x: 0, y: 0}`) when omitted. */
1879
+ positionAnchor?: UnitPoint;
1872
1880
  }
1873
1881
 
1874
1882
  export interface Style {
@@ -1940,6 +1948,15 @@ export interface RefreshEvent {
1940
1948
  type: 'refresh';
1941
1949
  id: ElementId;
1942
1950
  }
1951
+ /**
1952
+ * Sent to a scroll target (`scrollID`) inside a `scrollview` with
1953
+ * `reportsPosition`: scrolling settled with this target at the view's
1954
+ * anchor point. Sent once per change of target.
1955
+ */
1956
+ export interface ScrollPositionEvent {
1957
+ type: 'scrollposition';
1958
+ id: ElementId;
1959
+ }
1943
1960
  /**
1944
1961
  * A movable `listrow` was dragged to a new place: `to` is the index among
1945
1962
  * its container's children (the `list`'s, or its `section`'s) before which
@@ -1985,7 +2002,7 @@ export interface DropEvent {
1985
2002
  }
1986
2003
 
1987
2004
  /** The Phase 8 events (merged into `RendererEvent` above). */
1988
- export type InteractionEvent = DragEvent | PinchEvent | LongPressEvent | DeleteEvent | RefreshEvent | MoveEvent | DragStartEvent | DragEndEvent | DropEvent;
2005
+ export type InteractionEvent = DragEvent | PinchEvent | LongPressEvent | DeleteEvent | RefreshEvent | ScrollPositionEvent | MoveEvent | DragStartEvent | DragEndEvent | DropEvent;
1989
2006
  // Phase 8: alerts, controls, effects
1990
2007
  // (docs/ops-protocol.md, "Phase 8: interaction"; gestures, swipe actions,
1991
2008
  // context menus, list rows and refresh live in their own block)
@@ -2297,6 +2314,12 @@ export interface ScrollToCommand {
2297
2314
  anchor: UnitPoint | null;
2298
2315
  /** Increases with every request (across readers); the page carries out each serial once. */
2299
2316
  serial: number;
2317
+ /**
2318
+ * The axes to scroll on; both when omitted. `.scrollPosition(id:)` names
2319
+ * its scroll view's axes, so setting it scrolls that view alone and not a
2320
+ * list the view sits in.
2321
+ */
2322
+ axes?: ScrollAxes;
2300
2323
  }
2301
2324
 
2302
2325
  export interface Style {
package/src/renderer.ts CHANGED
@@ -151,6 +151,7 @@ import { VideoEvents, applyVideo, stopVideos } from './video';
151
151
  import { PinchController } from './pinch';
152
152
  import { applyCanvas, createCanvasElement } from './canvasDrawing';
153
153
  import { scrollToTarget } from './scrollTo';
154
+ import { POSITION_SETTLE_MS, anchorPoint, positionTarget, type PositionCandidate } from './scrollPosition';
154
155
  import { applyDefaultScrollAnchor, keepScrollAnchors } from './scrollAnchor';
155
156
  import { EMPTY_CATALOG, assetColorVar, type AssetCatalog } from './assets';
156
157
  import { HIERARCHY_OPACITY, colorToCSS, effectsCSS, styleToCSS, type PaintCSS } from './styles';
@@ -360,6 +361,10 @@ export class Renderer {
360
361
  */
361
362
  private rootSize: { width: number; height: number } | null = null;
362
363
  private readonly resizeObserver: ResizeObserver | null = null;
364
+ /** `reportsPosition` scroll views mid-scroll: the timer that calls the scroll settled (src/scrollPosition.ts). */
365
+ private readonly positionTimers = new Map<HTMLElement, number>();
366
+ /** The scroll target each `reportsPosition` scroll view last reported, so a settle at the same one says nothing. */
367
+ private readonly reportedPositions = new WeakMap<HTMLElement, number>();
363
368
  /**
364
369
  * `host` elements (src/hostContent.ts): their content elements, observed
365
370
  * for size changes, and the measurements of the current layout pass.
@@ -855,6 +860,8 @@ export class Renderer {
855
860
  root.addEventListener('focusout', this.onFocusChange);
856
861
  root.addEventListener('scroll', this.onScrollDismissKeyboard, true);
857
862
  root.addEventListener('scroll', this.queueLazyCheck, { capture: true, passive: true });
863
+ root.addEventListener('scroll', this.onPositionScroll, { capture: true, passive: true });
864
+ root.addEventListener('scrollend', this.onPositionScrollEnd, { capture: true, passive: true });
858
865
  this.videoEvents = new VideoEvents(root, (event) => this.onEvent(event));
859
866
  // Phase 8: pointer gestures (the sheet drag among them) are delegated too.
860
867
  const host: InteractionHost = {
@@ -1448,24 +1455,27 @@ export class Renderer {
1448
1455
  height += row.kind === 'caption' ? MENU_CAPTION_HEIGHT : row.kind === 'separator' ? MENU_SEPARATOR_HEIGHT : PICKER_MENU_ROW;
1449
1456
  }
1450
1457
  // Anchor to the control: trailing edges aligned, below it when there is
1451
- // room, else above; clamped inside the screen.
1458
+ // room, else above; clamped inside the screen. A menu taller than the
1459
+ // room on either side (a picker of every hour) takes the roomier side,
1460
+ // as tall as that room, and scrolls inside, opened at its checked row.
1452
1461
  const rootRect = this.root.getBoundingClientRect();
1453
1462
  const anchorRect = anchor.getBoundingClientRect();
1454
1463
  const width = Math.min(PICKER_MENU_WIDTH, Math.max(0, rootRect.width - 2 * PICKER_MENU_MARGIN));
1455
1464
  let left = anchorRect.right - rootRect.left - width;
1456
1465
  left = Math.max(PICKER_MENU_MARGIN, Math.min(left, rootRect.width - width - PICKER_MENU_MARGIN));
1457
- let top = anchorRect.bottom - rootRect.top + PICKER_MENU_GAP;
1458
- if (top + height > rootRect.height - PICKER_MENU_MARGIN) {
1459
- top = anchorRect.top - rootRect.top - PICKER_MENU_GAP - height;
1460
- menu.dataset['sbPlacement'] = 'above';
1461
- } else {
1462
- menu.dataset['sbPlacement'] = 'below';
1463
- }
1464
- top = Math.max(PICKER_MENU_MARGIN, top);
1466
+ const belowTop = anchorRect.bottom - rootRect.top + PICKER_MENU_GAP;
1467
+ const roomBelow = rootRect.height - PICKER_MENU_MARGIN - belowTop;
1468
+ const roomAbove = anchorRect.top - rootRect.top - PICKER_MENU_GAP - PICKER_MENU_MARGIN;
1469
+ const below = height <= roomBelow || (height > roomAbove && roomBelow >= roomAbove);
1470
+ const shown = Math.min(height, Math.max(0, below ? roomBelow : roomAbove));
1471
+ const top = below ? belowTop : anchorRect.top - rootRect.top - PICKER_MENU_GAP - shown;
1472
+ menu.dataset['sbPlacement'] = below ? 'below' : 'above';
1465
1473
  menu.style.left = `${Math.round(left)}px`;
1466
- menu.style.top = `${Math.round(top)}px`;
1474
+ menu.style.top = `${Math.round(Math.max(PICKER_MENU_MARGIN, top))}px`;
1467
1475
  menu.style.width = `${Math.round(width)}px`;
1476
+ if (shown < height) menu.style.maxHeight = `${Math.round(shown)}px`;
1468
1477
  this.root.append(menu);
1478
+ if (shown < height) menu.querySelector('[aria-checked="true"]')?.scrollIntoView({ block: 'nearest' });
1469
1479
  anchor.setAttribute('aria-expanded', 'true');
1470
1480
  this.menu = { el: menu, anchor, anchorId: id };
1471
1481
  document.addEventListener('click', this.onDocumentClick, true);
@@ -1562,6 +1572,10 @@ export class Renderer {
1562
1572
  this.root.removeEventListener('focusin', this.onFocusChange);
1563
1573
  this.root.removeEventListener('focusout', this.onFocusChange);
1564
1574
  this.root.removeEventListener('scroll', this.onScrollDismissKeyboard, true);
1575
+ this.root.removeEventListener('scroll', this.onPositionScroll, true);
1576
+ this.root.removeEventListener('scrollend', this.onPositionScrollEnd, true);
1577
+ for (const timer of this.positionTimers.values()) window.clearTimeout(timer);
1578
+ this.positionTimers.clear();
1565
1579
  this.interaction.dispose();
1566
1580
  this.pressEffects.dispose();
1567
1581
  this.externalDrops.dispose();
@@ -1649,6 +1663,60 @@ export class Renderer {
1649
1663
  }
1650
1664
  }
1651
1665
 
1666
+ // -------------------------------------------------------------------------
1667
+ // `.scrollPosition(id:)` reading back (src/scrollPosition.ts)
1668
+ // -------------------------------------------------------------------------
1669
+
1670
+ /** A `reportsPosition` scroll view scrolled: it has settled when no further scroll follows for a moment. */
1671
+ private readonly onPositionScroll = (event: Event): void => {
1672
+ const el = event.target;
1673
+ if (!(el instanceof HTMLElement) || el.dataset['sbReportsPosition'] === undefined) return;
1674
+ const pending = this.positionTimers.get(el);
1675
+ if (pending !== undefined) window.clearTimeout(pending);
1676
+ this.positionTimers.set(
1677
+ el,
1678
+ window.setTimeout(() => this.reportPosition(el), POSITION_SETTLE_MS),
1679
+ );
1680
+ };
1681
+
1682
+ /** The browser says the scroll ended (Chrome, Firefox; Safari waits for the timer). */
1683
+ private readonly onPositionScrollEnd = (event: Event): void => {
1684
+ const el = event.target;
1685
+ if (!(el instanceof HTMLElement) || el.dataset['sbReportsPosition'] === undefined) return;
1686
+ this.reportPosition(el);
1687
+ };
1688
+
1689
+ /**
1690
+ * Which scroll target of a settled scroll view is at its anchor point: the
1691
+ * `scrollID` boxes inside it whose nearest scrolling ancestor it is, along
1692
+ * the axis it scrolls on (the vertical one when it scrolls both ways). Sent
1693
+ * to that target when it is not the one reported last.
1694
+ */
1695
+ private reportPosition(el: HTMLElement): void {
1696
+ const pending = this.positionTimers.get(el);
1697
+ if (pending !== undefined) window.clearTimeout(pending);
1698
+ this.positionTimers.delete(el);
1699
+ const node = this.nodes.get(Number(el.dataset[ID_ATTR]));
1700
+ if (!node || !el.isConnected) return;
1701
+ const horizontal = node.props['axes'] === 'horizontal';
1702
+ const anchor = node.props['positionAnchor'] as { x?: unknown; y?: unknown } | undefined;
1703
+ const fraction = horizontal ? anchor?.x : anchor?.y;
1704
+ const box = el.getBoundingClientRect();
1705
+ const point = anchorPoint(horizontal ? { start: box.left, length: box.width } : { start: box.top, length: box.height }, typeof fraction === 'number' ? fraction : 0);
1706
+ const candidates: PositionCandidate[] = [];
1707
+ for (const target of el.querySelectorAll<HTMLElement>('[data-sb-id]')) {
1708
+ const id = Number(target.dataset[ID_ATTR]);
1709
+ const style = (this.nodes.get(id)?.props as Partial<StyledProps> | undefined)?.style;
1710
+ if (!style?.scrollID || target.parentElement?.closest('.sb-scrollview, .sb-list') !== el) continue;
1711
+ const rect = target.getBoundingClientRect();
1712
+ candidates.push(horizontal ? { id, start: rect.left, length: rect.width } : { id, start: rect.top, length: rect.height });
1713
+ }
1714
+ const target = positionTarget(candidates, point);
1715
+ if (target === null || this.reportedPositions.get(el) === target) return;
1716
+ this.reportedPositions.set(el, target);
1717
+ this.onEvent({ type: 'scrollposition', id: target });
1718
+ }
1719
+
1652
1720
  /** A `styled` whose style carries a `scrollTo` not seen before is scrolled to after this pass's layout. */
1653
1721
  private noteScroll(id: ElementId, kind: ElementKind, props: ElementProps): void {
1654
1722
  if (kind !== 'styled') return;
@@ -3134,6 +3202,8 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
3134
3202
  if (scroll.snap === 'viewAligned' || scroll.snap === 'paging') el.dataset['sbSnap'] = scroll.snap;
3135
3203
  else delete el.dataset['sbSnap'];
3136
3204
  if (scroll.dismissesKeyboard === true) el.dataset['sbDismissesKeyboard'] = 'true';
3205
+ if (scroll.reportsPosition === true) el.dataset['sbReportsPosition'] = '';
3206
+ else delete el.dataset['sbReportsPosition'];
3137
3207
  applyEdgeEffect(el, scroll.edgeEffect);
3138
3208
  applyDefaultScrollAnchor(el, scroll.defaultScrollAnchor);
3139
3209
  break;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `.scrollPosition(id:)` reading back (docs/ops-protocol.md, "Scrolling to a
3
+ * view"): a `scrollview` with `reportsPosition` tells the app which scroll
4
+ * target (`scrollID`) is at its anchor point once scrolling settles, as
5
+ * SwiftUI sets the binding to the id of the view at the anchor. The renderer
6
+ * gathers the targets' extents along the axis and asks `positionTarget`
7
+ * which one is there; the event goes to that target's element.
8
+ */
9
+
10
+ /** A scroll target's extent along the scroll axis, in the same coordinates as the viewport. */
11
+ export interface PositionCandidate {
12
+ id: number;
13
+ start: number;
14
+ length: number;
15
+ }
16
+
17
+ /** A settled scroll is one with no `scroll` event for this long (`scrollend`, where the browser has it, ends the wait). */
18
+ export const POSITION_SETTLE_MS = 120;
19
+
20
+ /**
21
+ * The target at `point` along the axis: the one whose extent holds the point
22
+ * (the last of several, as a later sibling draws over an earlier one), else
23
+ * the first one starting after it (the point is in a gap or before the
24
+ * content), else the last one (the point is past the end). Null without
25
+ * candidates; candidates of no extent are hidden and left out.
26
+ */
27
+ export function positionTarget(candidates: readonly PositionCandidate[], point: number): number | null {
28
+ const shown = candidates.filter((c) => c.length > 0);
29
+ if (shown.length === 0) return null;
30
+ let holding: PositionCandidate | null = null;
31
+ let next: PositionCandidate | null = null;
32
+ for (const c of shown) {
33
+ if (c.start <= point && point < c.start + c.length) holding = c;
34
+ else if (c.start > point && (!next || c.start < next.start)) next = c;
35
+ }
36
+ if (holding) return holding.id;
37
+ if (next) return next.id;
38
+ return shown.reduce((last, c) => (c.start >= last.start ? c : last)).id;
39
+ }
40
+
41
+ /** The anchor point of a viewport along the axis: its start plus the anchor's fraction of its length. */
42
+ export function anchorPoint(viewport: { start: number; length: number }, fraction: number): number {
43
+ return viewport.start + Math.min(1, Math.max(0, fraction)) * viewport.length;
44
+ }
package/src/scrollTo.ts CHANGED
@@ -82,8 +82,9 @@ export function scrollToTarget(
82
82
  const target = result.chrome.get(id)?.['row'] ?? result.frames.get(id);
83
83
  if (!target) return 0;
84
84
  const anchor = anchorOf(command);
85
- let horizontalDone = false;
86
- let verticalDone = false;
85
+ // Axes the command leaves out are done before they start.
86
+ let horizontalDone = command?.axes === 'vertical';
87
+ let verticalDone = command?.axes === 'horizontal';
87
88
  let scrolled = 0;
88
89
  for (let at = ctx.parents.get(id); at !== undefined && at !== 0; at = ctx.parents.get(at)) {
89
90
  const node = ctx.nodes.get(at);
package/src/styles.css CHANGED
@@ -1945,11 +1945,18 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
1945
1945
  backdrop-filter: blur(40px) saturate(180%);
1946
1946
  -webkit-backdrop-filter: blur(40px) saturate(180%);
1947
1947
  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.22);
1948
- overflow: hidden;
1948
+ /* Taller than the room it was given (`max-height`), it scrolls inside. */
1949
+ overflow: hidden auto;
1950
+ overscroll-behavior: contain;
1951
+ scrollbar-width: none;
1949
1952
  transform-origin: 50% 0;
1950
1953
  animation: sb-menu-in 0.18s cubic-bezier(0.2, 0.8, 0.2, 1) both;
1951
1954
  }
1952
1955
 
1956
+ .sb-picker-menu::-webkit-scrollbar {
1957
+ display: none;
1958
+ }
1959
+
1953
1960
  .sb-picker-menu-exit {
1954
1961
  pointer-events: none;
1955
1962
  animation: sb-menu-out 0.15s ease-in both;
@@ -2968,9 +2975,15 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2968
2975
  /* Swipe rows (`swipeactions`, deletable `listrow`): the content slot paints
2969
2976
  the row background and slides over the action buttons behind it. */
2970
2977
  .sb-swipeactions,
2971
- .sb-listrow {
2978
+ .sb-listrow,
2979
+ .sb-swipeactions {
2972
2980
  overflow: hidden;
2973
2981
  touch-action: pan-y;
2982
+ /* The swipe content's z-index (below) orders it over the actions within
2983
+ the row only: without its own stacking context it ranked with the
2984
+ screen's bar (z-index 1), and a row scrolled under the bar painted over
2985
+ the title. */
2986
+ isolation: isolate;
2974
2987
  }
2975
2988
 
2976
2989
  .sb-swipeactions > .sb-swipe-content,