@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 +1 -1
- package/src/layout/controls.ts +98 -45
- package/src/layout/engine.ts +17 -11
- package/src/layout/index.ts +1 -1
- package/src/protocol.ts +21 -2
- package/src/renderer.ts +90 -18
- package/src/scrollPosition.ts +44 -0
- package/src/styles.css +15 -2
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
|
@@ -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
|
|
3679
|
-
//
|
|
3680
|
-
//
|
|
3681
|
-
//
|
|
3682
|
-
const
|
|
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
|
|
4226
|
-
const
|
|
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);
|
package/src/layout/index.ts
CHANGED
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
|
|
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
|
-
|
|
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
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
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,
|
|
2966
|
-
* `min` / `max` (from the element's data) bound the date
|
|
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' ||
|
|
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
|
-
|
|
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,
|