@swiftbrowser/web 0.9.5 → 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/hostInset.ts +26 -0
- package/src/layout/controls.ts +98 -45
- package/src/layout/engine.ts +3 -2
- package/src/layout/index.ts +1 -1
- package/src/layoutDom.ts +24 -0
- package/src/protocol.ts +3 -1
- package/src/renderer.ts +10 -8
package/package.json
CHANGED
package/src/hostInset.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `host` that is a list row is drawn in the row's box (the card's width,
|
|
3
|
+
* the row's height with its padding), as every row is, while the engine
|
|
4
|
+
* measured and placed its content in the row's content frame inside it.
|
|
5
|
+
* The content element goes back into that frame, as a text row's text is
|
|
6
|
+
* padded into its own (layoutDom.ts), so DOM content that fills its box
|
|
7
|
+
* (`sizing: "fill"`, `height: 100%`) fills what it was measured for and
|
|
8
|
+
* not the row's padding too.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Rect } from './layout';
|
|
12
|
+
|
|
13
|
+
export interface HostContentInset {
|
|
14
|
+
left: number;
|
|
15
|
+
top: number;
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Where the content sits inside the element's box; null when the box is the frame (nothing to set). */
|
|
21
|
+
export function hostContentInset(box: Rect, frame: Rect): HostContentInset | null {
|
|
22
|
+
const left = frame.x - box.x;
|
|
23
|
+
const top = frame.y - box.y;
|
|
24
|
+
if (left === 0 && top === 0 && frame.width === box.width && frame.height === box.height) return null;
|
|
25
|
+
return { left, top, width: frame.width, height: frame.height };
|
|
26
|
+
}
|
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/layoutDom.ts
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
36
|
import type { LayoutEnvironment, LayoutNode, LayoutResult, Rect, ResolvedFont, TextLayout } from './layout';
|
|
37
|
+
import { hostContentInset } from './hostInset';
|
|
37
38
|
import { listRowBackgroundOf } from './layout';
|
|
38
39
|
import { styleToCSS } from './styles';
|
|
39
40
|
import type { ShapeStyle } from './protocol';
|
|
@@ -488,6 +489,26 @@ function setFlag(el: HTMLElement, key: string, value: string | null): void {
|
|
|
488
489
|
}
|
|
489
490
|
}
|
|
490
491
|
|
|
492
|
+
/**
|
|
493
|
+
* A host's content element goes back into the engine's frame when the host
|
|
494
|
+
* is drawn in a bigger box (a list row's): src/hostInset.ts. In its own
|
|
495
|
+
* frame the stylesheet's `width: 100%` (and `height: 100%` for `fill`) hold.
|
|
496
|
+
*/
|
|
497
|
+
function positionHostContent(el: HTMLElement, box: Rect, frame: Rect): void {
|
|
498
|
+
const content = el.querySelector<HTMLElement>(':scope > .sb-host-content');
|
|
499
|
+
if (!content) return;
|
|
500
|
+
const inset = hostContentInset(box, frame);
|
|
501
|
+
const s = content.style;
|
|
502
|
+
const left = inset ? `${inset.left}px` : '';
|
|
503
|
+
const top = inset ? `${inset.top}px` : '';
|
|
504
|
+
const width = inset ? `${inset.width}px` : '';
|
|
505
|
+
const height = inset ? `${inset.height}px` : '';
|
|
506
|
+
if (s.left !== left) s.left = left;
|
|
507
|
+
if (s.top !== top) s.top = top;
|
|
508
|
+
if (s.width !== width) s.width = width;
|
|
509
|
+
if (s.height !== height) s.height = height;
|
|
510
|
+
}
|
|
511
|
+
|
|
491
512
|
/**
|
|
492
513
|
* For leaf rows: pad the content frame back into place inside the row box.
|
|
493
514
|
* `shift` moves vertically centered content (a one-line field's text) down
|
|
@@ -617,6 +638,9 @@ function applyChrome(
|
|
|
617
638
|
padIntoFrame(el, box, frame);
|
|
618
639
|
return;
|
|
619
640
|
}
|
|
641
|
+
case 'host':
|
|
642
|
+
positionHostContent(el, box, frame);
|
|
643
|
+
return;
|
|
620
644
|
case 'image': {
|
|
621
645
|
const font = result.fonts.get(node.id);
|
|
622
646
|
if (font) {
|
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
|
}
|