@swiftbrowser/web 0.9.6 → 0.9.7

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.7",
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
  }
@@ -4222,8 +4222,9 @@ export class LayoutPass {
4222
4222
  const cctx = this.childContext(node, ctx);
4223
4223
  const seconds = num(node.props['seconds']) ?? 0;
4224
4224
  const components = str(node.props['components']) ?? 'dateAndTime';
4225
- const dateText = components === 'hourAndMinute' ? null : formatPickerDate(seconds);
4226
- const timeText = components === 'date' ? null : formatPickerTime(seconds);
4225
+ const timeZone = str(node.props['timeZone']) ?? undefined;
4226
+ const dateText = components === 'hourAndMinute' ? null : formatPickerDate(seconds, timeZone);
4227
+ const timeText = components === 'date' ? null : formatPickerTime(seconds, timeZone);
4227
4228
  const capsule = (text: string | null): number =>
4228
4229
  text === null ? 0 : this.measurer.measureWidth(text, ctx.font) + 2 * DATEPICKER_CAPSULE_PAD_H;
4229
4230
  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
@@ -2035,6 +2035,8 @@ export interface DatePickerProps {
2035
2035
  components: DatePickerComponents;
2036
2036
  min: number | null;
2037
2037
  max: number | null;
2038
+ /** The IANA time zone the date and time are shown and edited in (`\\.timeZone`); the viewer's when absent. */
2039
+ timeZone?: string;
2038
2040
  /** `.labelsHidden()`: the label child is not laid out. */
2039
2041
  labelsHidden?: boolean;
2040
2042
  }
@@ -2058,7 +2060,7 @@ export interface SlideEvent {
2058
2060
  id: ElementId;
2059
2061
  value: number;
2060
2062
  }
2061
- /** A `datepicker` changed: seconds since 1970, UTC. */
2063
+ /** A `datepicker` changed: seconds since 1970 (an instant; the zone only shaped the reading). */
2062
2064
  export interface DateEvent {
2063
2065
  type: 'date';
2064
2066
  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,
@@ -742,7 +742,7 @@ export class Renderer {
742
742
  const id = Number(picker?.dataset[ID_ATTR]);
743
743
  if (!picker || !Number.isInteger(id) || input.value === '') return;
744
744
  const seconds = Number(picker.dataset['sbSeconds']) || 0;
745
- const value = combineDateSeconds(seconds, input.type === 'date' ? { date: input.value } : { time: input.value });
745
+ const value = combineDateSeconds(seconds, input.type === 'date' ? { date: input.value } : { time: input.value }, picker.dataset['sbTimeZone'] || undefined);
746
746
  if (value === seconds) return;
747
747
  // Show the new value right away; Swift's `update` confirms (or reverts) it.
748
748
  picker.dataset['sbSeconds'] = String(value);
@@ -2957,14 +2957,15 @@ function applyDatePicker(el: HTMLElement, props: DatePickerProps): void {
2957
2957
  el.dataset['sbComponents'] = components;
2958
2958
  el.dataset['sbMin'] = props.min != null && Number.isFinite(props.min) ? String(props.min) : '';
2959
2959
  el.dataset['sbMax'] = props.max != null && Number.isFinite(props.max) ? String(props.max) : '';
2960
+ el.dataset['sbTimeZone'] = typeof props.timeZone === 'string' ? props.timeZone : '';
2960
2961
  if (props.labelsHidden === true) el.dataset['sbLabelsHidden'] = '';
2961
2962
  setDatePickerValue(el, seconds);
2962
2963
  }
2963
2964
 
2964
2965
  /**
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.
2966
+ * Show `seconds` in the capsules (US English, in the picker's zone) and in
2967
+ * the native inputs; `min` / `max` (from the element's data) bound the date
2968
+ * input, and the time input only when they fall on the shown day.
2968
2969
  */
2969
2970
  function setDatePickerValue(el: HTMLElement, seconds: number): void {
2970
2971
  const bound = (key: 'sbMin' | 'sbMax'): number | null => {
@@ -2973,19 +2974,20 @@ function setDatePickerValue(el: HTMLElement, seconds: number): void {
2973
2974
  };
2974
2975
  const min = bound('sbMin');
2975
2976
  const max = bound('sbMax');
2977
+ const zone = el.dataset['sbTimeZone'] || undefined;
2976
2978
  for (const type of ['date', 'time'] as const) {
2977
2979
  const capsule = el.querySelector<HTMLElement>(`:scope > .sb-datepicker-${type}`);
2978
2980
  if (!capsule) continue;
2979
2981
  const text = capsule.querySelector<HTMLElement>(':scope > .sb-datepicker-text');
2980
2982
  const input = capsule.querySelector<HTMLInputElement>(':scope > .sb-datepicker-input');
2981
- const label = type === 'date' ? formatPickerDate(seconds) : formatPickerTime(seconds);
2983
+ const label = type === 'date' ? formatPickerDate(seconds, zone) : formatPickerTime(seconds, zone);
2982
2984
  if (text && text.textContent !== label) text.textContent = label;
2983
2985
  if (!input) continue;
2984
- const format = type === 'date' ? dateInputValue : timeInputValue;
2986
+ const format = (s: number): string => (type === 'date' ? dateInputValue(s, zone) : timeInputValue(s, zone));
2985
2987
  const value = format(seconds);
2986
2988
  if (input.value !== value) input.value = value;
2987
2989
  const limit = (b: number | null): string =>
2988
- b !== null && Number.isFinite(b) && (type === 'date' || sameUTCDay(b, seconds)) ? format(b) : '';
2990
+ b !== null && Number.isFinite(b) && (type === 'date' || sameDay(b, seconds, zone)) ? format(b) : '';
2989
2991
  input.min = limit(min);
2990
2992
  input.max = limit(max);
2991
2993
  }