@swiftbrowser/web 0.9.6 → 0.9.8

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.6",
3
+ "version": "0.9.8",
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": {
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Phase 8 control helpers shared by the layout engine and the renderer
3
3
  * (docs/ops-protocol.md, "Phase 8: interaction", Controls). Pure and DOM-free:
4
- * slider value snapping and the US-English UTC formatting of a `datepicker`.
4
+ * slider value snapping and the US-English formatting of a `datepicker` in
5
+ * its time zone.
5
6
  */
6
7
 
7
8
  // Slider
@@ -45,72 +46,124 @@ export function sliderFraction(value: number, min: number, max: number): number
45
46
 
46
47
  const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
47
48
 
48
- function dateOf(seconds: number): Date {
49
- return new Date((Number.isFinite(seconds) ? seconds : 0) * 1000);
50
- }
51
-
52
49
  function pad2(n: number): string {
53
50
  return n < 10 ? `0${n}` : String(n);
54
51
  }
55
52
 
56
- /** "Oct 3, 2026": the UTC date of `seconds` in the US English abbreviated form. */
57
- export function formatPickerDate(seconds: number): string {
58
- const d = dateOf(seconds);
59
- return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`;
53
+ /** The wall clock of an instant in a zone. */
54
+ interface WallClock {
55
+ year: number;
56
+ month: number; // 0-based
57
+ day: number;
58
+ hours: number;
59
+ minutes: number;
60
+ }
61
+
62
+ /** The viewer's IANA zone, or UTC when the runtime cannot say. */
63
+ function localZone(): string {
64
+ try {
65
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
66
+ } catch {
67
+ return 'UTC';
68
+ }
69
+ }
70
+
71
+ const formatters = new Map<string, Intl.DateTimeFormat>();
72
+
73
+ /** A formatter giving an instant's wall clock in `zone`; an unknown zone reads as UTC. */
74
+ function formatterFor(zone: string): Intl.DateTimeFormat {
75
+ let format = formatters.get(zone);
76
+ if (!format) {
77
+ const options: Intl.DateTimeFormatOptions = { hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric' };
78
+ try {
79
+ format = new Intl.DateTimeFormat('en-US', { ...options, timeZone: zone });
80
+ } catch {
81
+ format = new Intl.DateTimeFormat('en-US', { ...options, timeZone: 'UTC' });
82
+ }
83
+ formatters.set(zone, format);
84
+ }
85
+ return format;
86
+ }
87
+
88
+ /** The zone a picker shows and edits in: the one it names, else the viewer's. */
89
+ function zoneOf(timeZone: string | undefined): string {
90
+ return timeZone || localZone();
91
+ }
92
+
93
+ /** The wall clock of `seconds` (since 1970) in `zone`. */
94
+ export function wallClock(seconds: number, timeZone?: string): WallClock {
95
+ const ms = (Number.isFinite(seconds) ? seconds : 0) * 1000;
96
+ const parts: Record<string, number> = {};
97
+ for (const part of formatterFor(zoneOf(timeZone)).formatToParts(new Date(ms))) {
98
+ if (part.type !== 'literal') parts[part.type] = Number(part.value);
99
+ }
100
+ // `h23` still reads "24" for midnight in some engines.
101
+ return { year: parts['year'] ?? 1970, month: (parts['month'] ?? 1) - 1, day: parts['day'] ?? 1, hours: (parts['hour'] ?? 0) % 24, minutes: parts['minute'] ?? 0 };
102
+ }
103
+
104
+ /** The instant (seconds since 1970) at a wall clock in `zone`; in a gap (a clock skipped forward) the later reading, in an overlap the earlier. */
105
+ export function instantOf(clock: WallClock, timeZone?: string): number {
106
+ const asUTC = (c: WallClock): number => Date.UTC(c.year, c.month, c.day, c.hours, c.minutes, 0) / 1000;
107
+ const wanted = asUTC(clock);
108
+ // The zone's offset at a nearby instant, then checked at the answer itself (a DST change may sit between).
109
+ let guess = wanted - (asUTC(wallClock(wanted, timeZone)) - wanted);
110
+ const again = wanted - (asUTC(wallClock(guess, timeZone)) - guess);
111
+ if (again !== guess && asUTC(wallClock(again, timeZone)) === wanted) guess = again;
112
+ return guess;
113
+ }
114
+
115
+ /** "Oct 3, 2026": the date of `seconds` in `timeZone` (the viewer's when absent), in the US English abbreviated form. */
116
+ export function formatPickerDate(seconds: number, timeZone?: string): string {
117
+ const c = wallClock(seconds, timeZone);
118
+ return `${MONTHS[c.month]} ${c.day}, ${c.year}`;
60
119
  }
61
120
 
62
- /** "7:00 AM": the UTC time of `seconds` in the US English 12-hour form. */
63
- export function formatPickerTime(seconds: number): string {
64
- const d = dateOf(seconds);
65
- const hours = d.getUTCHours();
66
- const period = hours < 12 ? 'AM' : 'PM';
67
- const hour12 = hours % 12 === 0 ? 12 : hours % 12;
68
- return `${hour12}:${pad2(d.getUTCMinutes())} ${period}`;
121
+ /** "7:00 AM": the time of `seconds` in `timeZone`, in the US English 12-hour form. */
122
+ export function formatPickerTime(seconds: number, timeZone?: string): string {
123
+ const c = wallClock(seconds, timeZone);
124
+ const period = c.hours < 12 ? 'AM' : 'PM';
125
+ const hour12 = c.hours % 12 === 0 ? 12 : c.hours % 12;
126
+ return `${hour12}:${pad2(c.minutes)} ${period}`;
69
127
  }
70
128
 
71
- /** `yyyy-mm-dd` for a native `<input type="date">`, in UTC. */
72
- export function dateInputValue(seconds: number): string {
73
- const d = dateOf(seconds);
74
- return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
129
+ /** `yyyy-mm-dd` for a native `<input type="date">`, in `timeZone`. */
130
+ export function dateInputValue(seconds: number, timeZone?: string): string {
131
+ const c = wallClock(seconds, timeZone);
132
+ return `${c.year}-${pad2(c.month + 1)}-${pad2(c.day)}`;
75
133
  }
76
134
 
77
- /** `HH:MM` for a native `<input type="time">`, in UTC. */
78
- export function timeInputValue(seconds: number): string {
79
- const d = dateOf(seconds);
80
- return `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}`;
135
+ /** `HH:MM` for a native `<input type="time">`, in `timeZone`. */
136
+ export function timeInputValue(seconds: number, timeZone?: string): string {
137
+ const c = wallClock(seconds, timeZone);
138
+ return `${pad2(c.hours)}:${pad2(c.minutes)}`;
81
139
  }
82
140
 
83
141
  /**
84
- * Seconds (UTC) after a native input changed: the `date` (`yyyy-mm-dd`)
85
- * replaces the day and keeps the time of day of `seconds`; the `time`
86
- * (`HH:MM`) replaces the time of day and keeps the day. Seconds within the
87
- * minute are dropped, like the pickers' minute granularity. An unparsable
88
- * value leaves `seconds` as it was.
142
+ * Seconds after a native input changed, read in `timeZone`: the `date`
143
+ * (`yyyy-mm-dd`) replaces the day and keeps the time of day of `seconds`;
144
+ * the `time` (`HH:MM`) replaces the time of day and keeps the day. Seconds
145
+ * within the minute are dropped, like the pickers' minute granularity. An
146
+ * unparsable value leaves `seconds` as it was.
89
147
  */
90
- export function combineDateSeconds(seconds: number, change: { date?: string; time?: string }): number {
91
- const d = dateOf(seconds);
92
- let year = d.getUTCFullYear();
93
- let month = d.getUTCMonth();
94
- let day = d.getUTCDate();
95
- let hours = d.getUTCHours();
96
- let minutes = d.getUTCMinutes();
148
+ export function combineDateSeconds(seconds: number, change: { date?: string; time?: string }, timeZone?: string): number {
149
+ const clock = wallClock(seconds, timeZone);
97
150
  if (change.date !== undefined) {
98
151
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(change.date);
99
152
  if (!m) return seconds;
100
- year = Number(m[1]);
101
- month = Number(m[2]) - 1;
102
- day = Number(m[3]);
153
+ clock.year = Number(m[1]);
154
+ clock.month = Number(m[2]) - 1;
155
+ clock.day = Number(m[3]);
103
156
  }
104
157
  if (change.time !== undefined) {
105
158
  const m = /^(\d{2}):(\d{2})/.exec(change.time);
106
159
  if (!m) return seconds;
107
- hours = Number(m[1]);
108
- minutes = Number(m[2]);
160
+ clock.hours = Number(m[1]);
161
+ clock.minutes = Number(m[2]);
109
162
  }
110
- return Math.floor(Date.UTC(year, month, day, hours, minutes, 0) / 1000);
163
+ return instantOf(clock, timeZone);
111
164
  }
112
165
 
113
- /** True when two instants fall on the same UTC day (a time input's `min`/`max` only apply then). */
114
- export function sameUTCDay(a: number, b: number): boolean {
115
- return dateInputValue(a) === dateInputValue(b);
166
+ /** True when two instants fall on the same day in `timeZone` (a time input's `min`/`max` only apply then). */
167
+ export function sameDay(a: number, b: number, timeZone?: string): boolean {
168
+ return dateInputValue(a, timeZone) === dateInputValue(b, timeZone);
116
169
  }
@@ -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
  }
@@ -4222,8 +4227,9 @@ export class LayoutPass {
4222
4227
  const cctx = this.childContext(node, ctx);
4223
4228
  const seconds = num(node.props['seconds']) ?? 0;
4224
4229
  const components = str(node.props['components']) ?? 'dateAndTime';
4225
- const dateText = components === 'hourAndMinute' ? null : formatPickerDate(seconds);
4226
- const timeText = components === 'date' ? null : formatPickerTime(seconds);
4230
+ const timeZone = str(node.props['timeZone']) ?? undefined;
4231
+ const dateText = components === 'hourAndMinute' ? null : formatPickerDate(seconds, timeZone);
4232
+ const timeText = components === 'date' ? null : formatPickerTime(seconds, timeZone);
4227
4233
  const capsule = (text: string | null): number =>
4228
4234
  text === null ? 0 : this.measurer.measureWidth(text, ctx.font) + 2 * DATEPICKER_CAPSULE_PAD_H;
4229
4235
  const dateWidth = capsule(dateText);
@@ -88,7 +88,7 @@ export {
88
88
  dateInputValue,
89
89
  formatPickerDate,
90
90
  formatPickerTime,
91
- sameUTCDay,
91
+ sameDay,
92
92
  sliderFraction,
93
93
  snapSliderValue,
94
94
  timeInputValue,
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)
@@ -2035,6 +2052,8 @@ export interface DatePickerProps {
2035
2052
  components: DatePickerComponents;
2036
2053
  min: number | null;
2037
2054
  max: number | null;
2055
+ /** The IANA time zone the date and time are shown and edited in (`\\.timeZone`); the viewer's when absent. */
2056
+ timeZone?: string;
2038
2057
  /** `.labelsHidden()`: the label child is not laid out. */
2039
2058
  labelsHidden?: boolean;
2040
2059
  }
@@ -2058,7 +2077,7 @@ export interface SlideEvent {
2058
2077
  id: ElementId;
2059
2078
  value: number;
2060
2079
  }
2061
- /** A `datepicker` changed: seconds since 1970, UTC. */
2080
+ /** A `datepicker` changed: seconds since 1970 (an instant; the zone only shaped the reading). */
2062
2081
  export interface DateEvent {
2063
2082
  type: 'date';
2064
2083
  id: ElementId;
package/src/renderer.ts CHANGED
@@ -140,7 +140,7 @@ import {
140
140
  dateInputValue,
141
141
  formatPickerDate,
142
142
  formatPickerTime,
143
- sameUTCDay,
143
+ sameDay,
144
144
  sliderFraction,
145
145
  snapSliderValue,
146
146
  timeInputValue,
@@ -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.
@@ -742,7 +747,7 @@ export class Renderer {
742
747
  const id = Number(picker?.dataset[ID_ATTR]);
743
748
  if (!picker || !Number.isInteger(id) || input.value === '') return;
744
749
  const seconds = Number(picker.dataset['sbSeconds']) || 0;
745
- const value = combineDateSeconds(seconds, input.type === 'date' ? { date: input.value } : { time: input.value });
750
+ const value = combineDateSeconds(seconds, input.type === 'date' ? { date: input.value } : { time: input.value }, picker.dataset['sbTimeZone'] || undefined);
746
751
  if (value === seconds) return;
747
752
  // Show the new value right away; Swift's `update` confirms (or reverts) it.
748
753
  picker.dataset['sbSeconds'] = String(value);
@@ -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;
@@ -2957,14 +3025,15 @@ function applyDatePicker(el: HTMLElement, props: DatePickerProps): void {
2957
3025
  el.dataset['sbComponents'] = components;
2958
3026
  el.dataset['sbMin'] = props.min != null && Number.isFinite(props.min) ? String(props.min) : '';
2959
3027
  el.dataset['sbMax'] = props.max != null && Number.isFinite(props.max) ? String(props.max) : '';
3028
+ el.dataset['sbTimeZone'] = typeof props.timeZone === 'string' ? props.timeZone : '';
2960
3029
  if (props.labelsHidden === true) el.dataset['sbLabelsHidden'] = '';
2961
3030
  setDatePickerValue(el, seconds);
2962
3031
  }
2963
3032
 
2964
3033
  /**
2965
- * Show `seconds` in the capsules (US English, UTC) and in the native inputs;
2966
- * `min` / `max` (from the element's data) bound the date input, and the time
2967
- * input only when they fall on the shown day.
3034
+ * Show `seconds` in the capsules (US English, in the picker's zone) and in
3035
+ * the native inputs; `min` / `max` (from the element's data) bound the date
3036
+ * input, and the time input only when they fall on the shown day.
2968
3037
  */
2969
3038
  function setDatePickerValue(el: HTMLElement, seconds: number): void {
2970
3039
  const bound = (key: 'sbMin' | 'sbMax'): number | null => {
@@ -2973,19 +3042,20 @@ function setDatePickerValue(el: HTMLElement, seconds: number): void {
2973
3042
  };
2974
3043
  const min = bound('sbMin');
2975
3044
  const max = bound('sbMax');
3045
+ const zone = el.dataset['sbTimeZone'] || undefined;
2976
3046
  for (const type of ['date', 'time'] as const) {
2977
3047
  const capsule = el.querySelector<HTMLElement>(`:scope > .sb-datepicker-${type}`);
2978
3048
  if (!capsule) continue;
2979
3049
  const text = capsule.querySelector<HTMLElement>(':scope > .sb-datepicker-text');
2980
3050
  const input = capsule.querySelector<HTMLInputElement>(':scope > .sb-datepicker-input');
2981
- const label = type === 'date' ? formatPickerDate(seconds) : formatPickerTime(seconds);
3051
+ const label = type === 'date' ? formatPickerDate(seconds, zone) : formatPickerTime(seconds, zone);
2982
3052
  if (text && text.textContent !== label) text.textContent = label;
2983
3053
  if (!input) continue;
2984
- const format = type === 'date' ? dateInputValue : timeInputValue;
3054
+ const format = (s: number): string => (type === 'date' ? dateInputValue(s, zone) : timeInputValue(s, zone));
2985
3055
  const value = format(seconds);
2986
3056
  if (input.value !== value) input.value = value;
2987
3057
  const limit = (b: number | null): string =>
2988
- b !== null && Number.isFinite(b) && (type === 'date' || sameUTCDay(b, seconds)) ? format(b) : '';
3058
+ b !== null && Number.isFinite(b) && (type === 'date' || sameDay(b, seconds, zone)) ? format(b) : '';
2989
3059
  input.min = limit(min);
2990
3060
  input.max = limit(max);
2991
3061
  }
@@ -3132,6 +3202,8 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
3132
3202
  if (scroll.snap === 'viewAligned' || scroll.snap === 'paging') el.dataset['sbSnap'] = scroll.snap;
3133
3203
  else delete el.dataset['sbSnap'];
3134
3204
  if (scroll.dismissesKeyboard === true) el.dataset['sbDismissesKeyboard'] = 'true';
3205
+ if (scroll.reportsPosition === true) el.dataset['sbReportsPosition'] = '';
3206
+ else delete el.dataset['sbReportsPosition'];
3135
3207
  applyEdgeEffect(el, scroll.edgeEffect);
3136
3208
  applyDefaultScrollAnchor(el, scroll.defaultScrollAnchor);
3137
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/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,