@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 +1 -1
- package/src/layout/controls.ts +98 -45
- package/src/layout/engine.ts +3 -2
- package/src/layout/index.ts +1 -1
- package/src/protocol.ts +3 -1
- package/src/renderer.ts +10 -8
package/package.json
CHANGED
package/src/layout/controls.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
63
|
-
export function formatPickerTime(seconds: number): string {
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
|
|
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
|
|
72
|
-
export function dateInputValue(seconds: number): string {
|
|
73
|
-
const
|
|
74
|
-
return `${
|
|
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
|
|
78
|
-
export function timeInputValue(seconds: number): string {
|
|
79
|
-
const
|
|
80
|
-
return `${pad2(
|
|
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
|
|
85
|
-
* replaces the day and keeps the time of day of `seconds`;
|
|
86
|
-
* (`HH:MM`) replaces the time of day and keeps the day. Seconds
|
|
87
|
-
* minute are dropped, like the pickers' minute granularity. An
|
|
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
|
|
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
|
|
163
|
+
return instantOf(clock, timeZone);
|
|
111
164
|
}
|
|
112
165
|
|
|
113
|
-
/** True when two instants fall on the same
|
|
114
|
-
export function
|
|
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
|
}
|
package/src/layout/engine.ts
CHANGED
|
@@ -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
|
|
4226
|
-
const
|
|
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);
|
package/src/layout/index.ts
CHANGED
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
|
|
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
|
-
|
|
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,
|
|
2966
|
-
* `min` / `max` (from the element's data) bound the date
|
|
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' ||
|
|
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
|
}
|