@swiftbrowser/web 0.9.2 → 0.9.4
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/README.md +19 -0
- package/package.json +1 -1
- package/src/browserHistory.ts +199 -0
- package/src/interaction.ts +10 -1
- package/src/layout/engine.ts +3 -0
- package/src/layoutDom.ts +25 -0
- package/src/main.ts +4 -0
- package/src/renderer.ts +84 -10
- package/src/styles.css +30 -8
- package/src/symbols.ts +15 -1
package/README.md
CHANGED
|
@@ -292,6 +292,25 @@ remains for the landing page and for `/app/` without a parameter. In the
|
|
|
292
292
|
`/manifest.webmanifest` is the app's, and the page is titled by the app's
|
|
293
293
|
display name alone.
|
|
294
294
|
|
|
295
|
+
## Browser history
|
|
296
|
+
|
|
297
|
+
`src/browserHistory.ts` mirrors the app's navigation in the browser's history
|
|
298
|
+
on the app page: pushing a screen or presenting a sheet adds an entry, and the
|
|
299
|
+
browser's back (a phone's edge swipe, the toolbar button, Alt+Left) pops or
|
|
300
|
+
dismisses it through the same `tap` the bar's back button and the sheet's
|
|
301
|
+
scrim send, so Swift's state stays the source of truth. A pop the app makes
|
|
302
|
+
itself takes the history back with it, and forward returns to the entry that
|
|
303
|
+
matches rather than re-pushing. Depth is read from the DOM after every
|
|
304
|
+
`commit`: the presented sheets plus the pushed screens of each visible stack
|
|
305
|
+
(the selected tab's, both columns of a split view, the top sheet's). The URL
|
|
306
|
+
is left alone. While it is on, the renderer's own edge swipe ignores touch and
|
|
307
|
+
pen: the browser's edge swipe is the swipe back, and it arrives as a
|
|
308
|
+
`popstate` (a mouse drag from the edge still pops through the renderer).
|
|
309
|
+
Its own `history.go` after a pop the app made itself is remembered until the
|
|
310
|
+
`popstate` lands, so commits meanwhile do not go back again.
|
|
311
|
+
An island has it on by default and opts out with `mount(..., { history: false })`
|
|
312
|
+
(`<SwiftUIView history={false}>` in @swiftbrowser/react).
|
|
313
|
+
|
|
295
314
|
## Deploying (Cloudflare Pages)
|
|
296
315
|
|
|
297
316
|
`npm run build` writes a static site to `dist/`: `index.html`, `app/index.html`,
|
package/package.json
CHANGED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser's history as the app's navigation: pushing a screen or
|
|
3
|
+
* presenting a sheet adds a history entry, and the browser's back (a
|
|
4
|
+
* phone's edge swipe, the toolbar button, Alt+Left) pops or dismisses it,
|
|
5
|
+
* as the bar's own back button would. Forward cannot re-push a screen, so a
|
|
6
|
+
* forward step lands back where the app is.
|
|
7
|
+
*
|
|
8
|
+
* Depth is read from the DOM after every commit: the presented sheets, plus
|
|
9
|
+
* the pushed screens of every visible stack (the selected tab's, both
|
|
10
|
+
* columns of a split view, the top sheet's). Pops go through the same event
|
|
11
|
+
* the back button and the scrim send, so the app's own state stays the
|
|
12
|
+
* source of truth; the page's URL is never touched, and an app that maps
|
|
13
|
+
* screens to paths should set them with `replaceState` on the entry the
|
|
14
|
+
* renderer made.
|
|
15
|
+
*
|
|
16
|
+
* While enabled, the renderer's own edge swipe to pop is off for touch and
|
|
17
|
+
* pen: the browser's edge swipe (Safari's, Android's back gesture) is the
|
|
18
|
+
* swipe back, and it lands here as a `popstate`. A mouse drag from the edge
|
|
19
|
+
* still pops through the renderer, since no browser turns one into history.
|
|
20
|
+
*/
|
|
21
|
+
import type { Renderer } from './renderer';
|
|
22
|
+
|
|
23
|
+
interface HistoryState {
|
|
24
|
+
/** Our marker; entries without it belong to the page or the app. */
|
|
25
|
+
sbHistory: true;
|
|
26
|
+
/** How many levels (screens and sheets) are above the base in this entry. */
|
|
27
|
+
sbDepth: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What the renderer's elements need to say for this to read them (`sbId`, `sbDepth` datasets). */
|
|
31
|
+
const stateOf = (value: unknown): HistoryState | null => (typeof value === 'object' && value !== null && (value as HistoryState).sbHistory === true ? (value as HistoryState) : null);
|
|
32
|
+
|
|
33
|
+
function shown(win: Window, el: HTMLElement): boolean {
|
|
34
|
+
if (el.hidden || el.getClientRects().length === 0) return false;
|
|
35
|
+
return win.getComputedStyle(el).visibility !== 'hidden';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class BrowserHistory {
|
|
39
|
+
private readonly win: Window;
|
|
40
|
+
/** Levels the latest `popstate` still asks to dismiss, one commit at a time. */
|
|
41
|
+
private pendingPops = 0;
|
|
42
|
+
/** `popstate` events to ignore: the ones our own `history.go` causes. */
|
|
43
|
+
private ignorePops = 0;
|
|
44
|
+
/**
|
|
45
|
+
* The depth our own `history.go` is travelling to while its `popstate` is
|
|
46
|
+
* still on its way (`go` is asynchronous): commits meanwhile compare
|
|
47
|
+
* against it, so a second commit does not go back a second time.
|
|
48
|
+
*/
|
|
49
|
+
private movingTo: number | null = null;
|
|
50
|
+
private movingTimer: ReturnType<typeof setTimeout> | null = null;
|
|
51
|
+
private enabled = false;
|
|
52
|
+
|
|
53
|
+
constructor(
|
|
54
|
+
private readonly renderer: Renderer,
|
|
55
|
+
private readonly root: HTMLElement,
|
|
56
|
+
win: Window = window,
|
|
57
|
+
) {
|
|
58
|
+
this.win = win;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Marks the current entry as the base and starts mirroring pushes and pops. */
|
|
62
|
+
enable(): void {
|
|
63
|
+
if (this.enabled) return;
|
|
64
|
+
this.enabled = true;
|
|
65
|
+
const current = stateOf(this.win.history.state);
|
|
66
|
+
if (!current) this.win.history.replaceState({ ...(this.win.history.state ?? {}), sbHistory: true, sbDepth: 0 }, '');
|
|
67
|
+
this.renderer.interaction.nativeBackGesture = true;
|
|
68
|
+
this.root.addEventListener('sb:commit', this.onCommit);
|
|
69
|
+
this.win.addEventListener('popstate', this.onPopState);
|
|
70
|
+
this.sync();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
dispose(): void {
|
|
74
|
+
if (!this.enabled) return;
|
|
75
|
+
this.enabled = false;
|
|
76
|
+
this.renderer.interaction.nativeBackGesture = false;
|
|
77
|
+
if (this.movingTimer) clearTimeout(this.movingTimer);
|
|
78
|
+
this.movingTo = null;
|
|
79
|
+
this.root.removeEventListener('sb:commit', this.onCommit);
|
|
80
|
+
this.win.removeEventListener('popstate', this.onPopState);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The levels above the base the app shows now: sheets, plus pushed screens in each visible stack of the top layer. */
|
|
84
|
+
depth(): number {
|
|
85
|
+
const sheets = this.sheets();
|
|
86
|
+
const top = sheets.at(-1) ?? this.root;
|
|
87
|
+
let depth = sheets.length;
|
|
88
|
+
for (const stack of this.stacks(top)) depth += this.topDepth(stack);
|
|
89
|
+
return depth;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private sheets(): HTMLElement[] {
|
|
93
|
+
return [...this.root.querySelectorAll<HTMLElement>(':scope > .sb-sheet')].filter((sheet) => shown(this.win, sheet));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The visible navigation stacks within `container`, not those inside a deeper sheet. */
|
|
97
|
+
private stacks(container: HTMLElement): HTMLElement[] {
|
|
98
|
+
return [...container.querySelectorAll<HTMLElement>('.sb-navstack')].filter((stack) => shown(this.win, stack) && stack.closest('.sb-sheet') === (container.classList.contains('sb-sheet') ? container : null));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private topScreen(stack: HTMLElement): HTMLElement | null {
|
|
102
|
+
const screens = stack.querySelectorAll<HTMLElement>(':scope > .sb-slot > .sb-navscreen');
|
|
103
|
+
return screens.length ? screens[screens.length - 1]! : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private topDepth(stack: HTMLElement): number {
|
|
107
|
+
const top = this.topScreen(stack);
|
|
108
|
+
return top ? Number(top.dataset['sbDepth'] ?? '0') || 0 : 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Asks the app to dismiss one level: the top sheet when nothing is pushed inside it, else the deepest visible stack's top screen. */
|
|
112
|
+
private dismissOne(): boolean {
|
|
113
|
+
const sheets = this.sheets();
|
|
114
|
+
const top = sheets.at(-1) ?? this.root;
|
|
115
|
+
const stacks = this.stacks(top)
|
|
116
|
+
.map((stack) => ({ stack, depth: this.topDepth(stack) }))
|
|
117
|
+
.filter((s) => s.depth > 0)
|
|
118
|
+
.sort((a, b) => b.depth - a.depth);
|
|
119
|
+
const deepest = stacks[0];
|
|
120
|
+
if (deepest) {
|
|
121
|
+
const screen = this.topScreen(deepest.stack);
|
|
122
|
+
const id = Number(screen?.dataset['sbId']);
|
|
123
|
+
if (!Number.isInteger(id)) return false;
|
|
124
|
+
this.renderer.tap(id); // what the back button sends
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
const sheet = sheets.at(-1);
|
|
128
|
+
const id = Number(sheet?.dataset['sbId']);
|
|
129
|
+
if (!Number.isInteger(id)) return false;
|
|
130
|
+
this.renderer.tap(id); // a dismiss request, as the scrim's tap
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private recorded(): number {
|
|
135
|
+
return this.movingTo ?? stateOf(this.win.history.state)?.sbDepth ?? 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Our own `history.go`, remembered until its `popstate` lands (or a moment passes without one: a `go` past the ends fires none). */
|
|
139
|
+
private go(delta: number, to: number): void {
|
|
140
|
+
this.ignorePops += 1;
|
|
141
|
+
this.movingTo = to;
|
|
142
|
+
if (this.movingTimer) clearTimeout(this.movingTimer);
|
|
143
|
+
this.movingTimer = setTimeout(() => {
|
|
144
|
+
if (this.movingTo === null) return;
|
|
145
|
+
this.movingTo = null;
|
|
146
|
+
this.ignorePops = Math.max(0, this.ignorePops - 1);
|
|
147
|
+
}, 1000);
|
|
148
|
+
this.win.history.go(delta);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private arrived(): void {
|
|
152
|
+
this.movingTo = null;
|
|
153
|
+
if (this.movingTimer) clearTimeout(this.movingTimer);
|
|
154
|
+
this.movingTimer = null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private readonly onCommit = (): void => this.sync();
|
|
158
|
+
|
|
159
|
+
/** After a commit: new levels get entries; levels the app dismissed itself take the history back with them. */
|
|
160
|
+
sync(): void {
|
|
161
|
+
if (!this.enabled) return;
|
|
162
|
+
const depth = this.depth();
|
|
163
|
+
const recorded = this.recorded();
|
|
164
|
+
if (this.pendingPops > 0) {
|
|
165
|
+
// Dismissing for a `popstate`: the entry already names the target depth.
|
|
166
|
+
if (depth > recorded) {
|
|
167
|
+
if (!this.dismissOne()) this.pendingPops = 0;
|
|
168
|
+
else this.pendingPops -= 1;
|
|
169
|
+
} else this.pendingPops = 0;
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (depth > recorded) {
|
|
173
|
+
for (let level = recorded + 1; level <= depth; level++) this.win.history.pushState({ sbHistory: true, sbDepth: level } satisfies HistoryState, '');
|
|
174
|
+
} else if (depth < recorded) {
|
|
175
|
+
// The app popped or dismissed on its own (back button, swipe, code): leave those entries.
|
|
176
|
+
this.go(depth - recorded, depth);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private readonly onPopState = (event: PopStateEvent): void => {
|
|
181
|
+
if (this.ignorePops > 0) {
|
|
182
|
+
this.ignorePops -= 1;
|
|
183
|
+
this.arrived();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const target = stateOf(event.state);
|
|
187
|
+
if (!target) return; // an entry of the page's own (before the base, or the app's router): not ours
|
|
188
|
+
const depth = this.depth();
|
|
189
|
+
if (target.sbDepth < depth) {
|
|
190
|
+
// Back: dismiss one level now, the rest as each commit lands.
|
|
191
|
+
this.pendingPops = depth - target.sbDepth;
|
|
192
|
+
if (!this.dismissOne()) this.pendingPops = 0;
|
|
193
|
+
else this.pendingPops -= 1;
|
|
194
|
+
} else if (target.sbDepth > depth) {
|
|
195
|
+
// Forward: a screen cannot be re-pushed; return to the entry that matches.
|
|
196
|
+
this.go(depth - target.sbDepth, depth);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
}
|
package/src/interaction.ts
CHANGED
|
@@ -202,6 +202,14 @@ export class InteractionController {
|
|
|
202
202
|
private session: Session | null = null;
|
|
203
203
|
private openSwipe: OpenSwipe | null = null;
|
|
204
204
|
private swipeBack: SwipeBackState | null = null;
|
|
205
|
+
/**
|
|
206
|
+
* The browser's own back gesture is in charge (browserHistory.ts mirrors
|
|
207
|
+
* navigation in the history): the renderer's edge swipe then ignores touch
|
|
208
|
+
* and pen, whose edge swipe the browser turns into a `popstate` that pops,
|
|
209
|
+
* so running both would pop twice. A mouse drag from the edge still pops
|
|
210
|
+
* here: no browser navigates on one.
|
|
211
|
+
*/
|
|
212
|
+
nativeBackGesture = false;
|
|
205
213
|
/** Wheel-driven swipe of a row: the host, its live offset and the idle timer that settles it. */
|
|
206
214
|
private wheelSwipe: { host: SwipeHost; offset: number; timer: number } | null = null;
|
|
207
215
|
/** Wheel-driven pull at the top of a refreshable scroller. */
|
|
@@ -426,7 +434,8 @@ export class InteractionController {
|
|
|
426
434
|
}
|
|
427
435
|
const byPress = event.pointerType !== 'mouse';
|
|
428
436
|
const screen = target.closest<HTMLElement>('.sb-navscreen');
|
|
429
|
-
|
|
437
|
+
const browserSwipes = this.nativeBackGesture && event.pointerType !== 'mouse';
|
|
438
|
+
if (!browserSwipes && screen && screen.dataset['sbNavPosition'] === 'top' && Number(screen.dataset['sbDepth'] ?? '0') > 0 && !this.swipeBack) {
|
|
430
439
|
const rect = screen.getBoundingClientRect();
|
|
431
440
|
const id = Number(screen.dataset[ID_ATTR]);
|
|
432
441
|
if (event.clientX - rect.left <= SWIPE_BACK_EDGE && Number.isInteger(id)) {
|
package/src/layout/engine.ts
CHANGED
|
@@ -3130,6 +3130,9 @@ export class LayoutPass {
|
|
|
3130
3130
|
this.placeDatePicker(node, ctx, p, x, y, size);
|
|
3131
3131
|
return;
|
|
3132
3132
|
case 'texteditor':
|
|
3133
|
+
// It draws in the context font, as a field does (the page's own
|
|
3134
|
+
// font otherwise: a serif where the page sets none).
|
|
3135
|
+
if (node.id >= 0) this.fonts.set(node.id, ctx.font);
|
|
3133
3136
|
return;
|
|
3134
3137
|
case 'swipeactions':
|
|
3135
3138
|
case 'contextmenu': {
|
package/src/layoutDom.ts
CHANGED
|
@@ -492,6 +492,30 @@ function padIntoFrame(el: HTMLElement, box: Rect, frame: Rect, shift = 0): void
|
|
|
492
492
|
if (s.paddingBottom !== paddingBottom) s.paddingBottom = paddingBottom;
|
|
493
493
|
}
|
|
494
494
|
|
|
495
|
+
/**
|
|
496
|
+
* A vertical field's textarea draws every line a leading apart, where the
|
|
497
|
+
* engine sized it as a Text: its first line a line height tall, the rest a
|
|
498
|
+
* leading apart. The textarea's lines need the difference more (2 px for
|
|
499
|
+
* the body style); without it they overflow by that much, and the textarea
|
|
500
|
+
* scrolls its text up while it is edited. The difference comes out of the
|
|
501
|
+
* padding below the lines (a list row's, a rounded border's), else the box
|
|
502
|
+
* reaches that much further down (into the space below it, which its lines
|
|
503
|
+
* leave empty).
|
|
504
|
+
*/
|
|
505
|
+
function fitFieldLines(el: HTMLTextAreaElement, font: ResolvedFont, box: Rect, frame: Rect): void {
|
|
506
|
+
const extra = Math.ceil(font.leading - font.lineHeight - 0.01);
|
|
507
|
+
if (extra <= 0) return;
|
|
508
|
+
const s = el.style;
|
|
509
|
+
const below = box.y + box.height - (frame.y + frame.height);
|
|
510
|
+
const padding = below > 0 ? below : el.dataset['sbStyle'] === 'roundedBorder' ? ROUNDED_FIELD_INSET : 0;
|
|
511
|
+
s.paddingBottom = `${Math.max(0, padding - extra)}px`;
|
|
512
|
+
const height = `${box.height + Math.max(0, extra - padding)}px`;
|
|
513
|
+
if (s.height !== height) s.height = height;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** A rounded border field's padding above and below its lines (styles.css: the engine's inset of 6, less the 1 px border). */
|
|
517
|
+
const ROUNDED_FIELD_INSET = 5;
|
|
518
|
+
|
|
495
519
|
const fieldCapShifts = new Map<string, number>();
|
|
496
520
|
|
|
497
521
|
/**
|
|
@@ -597,6 +621,7 @@ function applyChrome(
|
|
|
597
621
|
? fieldCapShift(font) + (line ? line.y - frame.y - (frame.height - font.lineHeight) / 2 : 0)
|
|
598
622
|
: 0;
|
|
599
623
|
padIntoFrame(el, box, frame, shift);
|
|
624
|
+
if (el instanceof HTMLTextAreaElement && node.kind === 'textfield' && font) fitFieldLines(el, font, box, frame);
|
|
600
625
|
return;
|
|
601
626
|
}
|
|
602
627
|
// Phase 8 controls: their chrome sits at the engine's rects inside the element's box.
|
package/src/main.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type RendererEvent,
|
|
15
15
|
type ScenePhase,
|
|
16
16
|
} from './protocol';
|
|
17
|
+
import { BrowserHistory } from './browserHistory';
|
|
17
18
|
import { Renderer, RendererError } from './renderer';
|
|
18
19
|
import { withBlockedHosts } from './blockedHosts';
|
|
19
20
|
import { withCORSProxy } from './corsProxy';
|
|
@@ -97,6 +98,9 @@ function dispatch(event: RendererEvent): void {
|
|
|
97
98
|
|
|
98
99
|
const renderer = new Renderer(screen, dispatch);
|
|
99
100
|
|
|
101
|
+
// The browser's back pops the app's screens and dismisses its sheets (browserHistory.ts).
|
|
102
|
+
new BrowserHistory(renderer, screen).enable();
|
|
103
|
+
|
|
100
104
|
// On a phone the browser's status bar strip and safe areas take the color the
|
|
101
105
|
// app draws at its top edge (chromeColor.ts).
|
|
102
106
|
const chromeColor = deviceMode ? new ChromeColor(device, screen) : null;
|
package/src/renderer.ts
CHANGED
|
@@ -433,10 +433,35 @@ export class Renderer {
|
|
|
433
433
|
/** The highest `scrollTo` serial seen: a command keeps its serial until the reader re-runs, and runs once. */
|
|
434
434
|
private scrollSerial = 0;
|
|
435
435
|
|
|
436
|
+
/**
|
|
437
|
+
* A row that taps its button (`rowAction`) highlights while pressed, as a
|
|
438
|
+
* row that is a button does (`:active`, styles.css): until the pointer is
|
|
439
|
+
* released or the press turns into a scroll.
|
|
440
|
+
*/
|
|
441
|
+
private readonly onRowPress = (event: PointerEvent): void => {
|
|
442
|
+
const hit = event.target as HTMLElement | null;
|
|
443
|
+
if (!hit || !event.isPrimary || hit.closest('.sb-button, .sb-navlink')) return;
|
|
444
|
+
const button = rowAction(hit, this.root);
|
|
445
|
+
const row = button ? listRowOf(button, this.root) : null;
|
|
446
|
+
if (!row) return;
|
|
447
|
+
row.dataset['sbRowPressed'] = '';
|
|
448
|
+
const release = (): void => {
|
|
449
|
+
delete row.dataset['sbRowPressed'];
|
|
450
|
+
window.removeEventListener('pointerup', release, true);
|
|
451
|
+
window.removeEventListener('pointercancel', release, true);
|
|
452
|
+
this.root.removeEventListener('scroll', release, true);
|
|
453
|
+
};
|
|
454
|
+
window.addEventListener('pointerup', release, true);
|
|
455
|
+
window.addEventListener('pointercancel', release, true);
|
|
456
|
+
this.root.addEventListener('scroll', release, true);
|
|
457
|
+
};
|
|
458
|
+
|
|
436
459
|
private readonly onClick = (event: MouseEvent): void => {
|
|
437
460
|
// `closest` picks the innermost control, so a button inside a
|
|
438
|
-
// `tapgesture` (or a menu label) handles the click alone.
|
|
439
|
-
|
|
461
|
+
// `tapgesture` (or a menu label) handles the click alone. Elsewhere in
|
|
462
|
+
// a list row, the row's one button (`rowAction`).
|
|
463
|
+
const hit = event.target as HTMLElement | null;
|
|
464
|
+
const target = hit?.closest<HTMLElement>(
|
|
440
465
|
'.sb-button, .sb-navlink, .sb-navbar-back, .sb-navbar-more, .sb-navsplit-toggle, .sb-switch, .sb-sheet-scrim, ' +
|
|
441
466
|
'.sb-tabbar-item, .sb-segment, .sb-picker-option, .sb-picker-menu-item, .sb-picker[data-sb-style="menu"], ' +
|
|
442
467
|
'.sb-menu, .sb-tapgesture, .sb-stepper-button, .sb-alert-scrim, .sb-alert-ok, ' +
|
|
@@ -444,7 +469,7 @@ export class Renderer {
|
|
|
444
469
|
'.sb-swipe-action, .sb-listrow-control, .sb-listrow[data-sb-selectable], ' +
|
|
445
470
|
// A link in a rich text: innermost, so it wins over a tappable row around the text
|
|
446
471
|
'.sb-link',
|
|
447
|
-
);
|
|
472
|
+
) ?? (hit ? rowAction(hit, this.root) : null);
|
|
448
473
|
if (!target || !this.root.contains(target)) return;
|
|
449
474
|
if (target.classList.contains('sb-link')) {
|
|
450
475
|
const text = target.closest<HTMLElement>('.sb-text');
|
|
@@ -820,6 +845,7 @@ export class Renderer {
|
|
|
820
845
|
// Delegated listeners instead of one per control: elements can be
|
|
821
846
|
// created detached and moved around freely.
|
|
822
847
|
root.addEventListener('click', this.onClick);
|
|
848
|
+
root.addEventListener('pointerdown', this.onRowPress);
|
|
823
849
|
root.addEventListener('keydown', this.onKeyDown);
|
|
824
850
|
root.addEventListener('input', this.onInput);
|
|
825
851
|
root.addEventListener('focusin', this.onFocusChange);
|
|
@@ -1216,6 +1242,15 @@ export class Renderer {
|
|
|
1216
1242
|
/** Called after each layout pass (main.ts keeps the browser chrome's color in step). */
|
|
1217
1243
|
afterLayout: (() => void) | null = null;
|
|
1218
1244
|
|
|
1245
|
+
/**
|
|
1246
|
+
* Sends a tap for an element, as a click on it would: a `navscreen` pops
|
|
1247
|
+
* it (the back button's tap), a `sheet` asks to be dismissed (the scrim's).
|
|
1248
|
+
* For the page's own controls, such as the browser's history (browserHistory.ts).
|
|
1249
|
+
*/
|
|
1250
|
+
tap(id: ElementId): void {
|
|
1251
|
+
this.onEvent({ type: 'tap', id });
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1219
1254
|
/**
|
|
1220
1255
|
* Run the layout engine over the current tree and position every element.
|
|
1221
1256
|
* Returns the elements whose box changed. Runs at every `commit`; call it
|
|
@@ -1502,6 +1537,7 @@ export class Renderer {
|
|
|
1502
1537
|
dispose(): void {
|
|
1503
1538
|
this.closeMenu();
|
|
1504
1539
|
this.root.removeEventListener('click', this.onClick);
|
|
1540
|
+
this.root.removeEventListener('pointerdown', this.onRowPress);
|
|
1505
1541
|
this.root.removeEventListener('keydown', this.onKeyDown);
|
|
1506
1542
|
this.root.removeEventListener('input', this.onInput);
|
|
1507
1543
|
this.root.removeEventListener('focusin', this.onFocusChange);
|
|
@@ -1688,12 +1724,17 @@ export class Renderer {
|
|
|
1688
1724
|
// props produced before applying the new ones. The layout box is kept:
|
|
1689
1725
|
// the engine re-lays out at `commit`, and removals recorded later in the
|
|
1690
1726
|
// pass measure their old position against intact ancestors.
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
if (
|
|
1695
|
-
|
|
1696
|
-
|
|
1727
|
+
// A text input's props produce no inline style (layout sets its box,
|
|
1728
|
+
// font and padding), and it is updated on every keystroke while it is
|
|
1729
|
+
// being edited: its style stays as it is.
|
|
1730
|
+
if (kind !== 'textfield' && kind !== 'texteditor') {
|
|
1731
|
+
const { left, top, width, height } = el.style;
|
|
1732
|
+
el.removeAttribute('style');
|
|
1733
|
+
if (left) el.style.left = left;
|
|
1734
|
+
if (top) el.style.top = top;
|
|
1735
|
+
if (width) el.style.width = width;
|
|
1736
|
+
if (height) el.style.height = height;
|
|
1737
|
+
}
|
|
1697
1738
|
for (const key of Object.keys(el.dataset)) {
|
|
1698
1739
|
if (key !== ID_ATTR && key !== KIND_ATTR) delete el.dataset[key];
|
|
1699
1740
|
}
|
|
@@ -2510,6 +2551,38 @@ function createShell(kind: ElementKind): HTMLElement {
|
|
|
2510
2551
|
}
|
|
2511
2552
|
}
|
|
2512
2553
|
|
|
2554
|
+
/** The list row `el` is in (a direct child of a section's rows or a list's content), or null. */
|
|
2555
|
+
function listRowOf(el: HTMLElement, root: HTMLElement): HTMLElement | null {
|
|
2556
|
+
for (let node: HTMLElement | null = el; node && node !== root; node = node.parentElement) {
|
|
2557
|
+
const parent = node.parentElement;
|
|
2558
|
+
if (parent?.classList.contains('sb-section-rows') || parent?.classList.contains('sb-list-content')) return node;
|
|
2559
|
+
}
|
|
2560
|
+
return null;
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
/** Controls a row may hold besides its button for the row to stay the button's: none. */
|
|
2564
|
+
const ROW_CONTROLS =
|
|
2565
|
+
'input, textarea, .sb-navlink, .sb-switch, .sb-picker, .sb-stepper, .sb-slider, .sb-menu, .sb-tapgesture, .sb-link, .sb-datepicker';
|
|
2566
|
+
|
|
2567
|
+
/**
|
|
2568
|
+
* A list row holding one button (with other views beside it, or under a
|
|
2569
|
+
* modifier) taps the button wherever it is tapped, as a UITableView cell
|
|
2570
|
+
* does on iOS; a row that is the button itself is that already. Not a
|
|
2571
|
+
* `.plain` or `.borderless` button (both arrive as `plain`), which SwiftUI
|
|
2572
|
+
* keeps to its own label in a row, a disabled one, or a row with other
|
|
2573
|
+
* controls.
|
|
2574
|
+
*/
|
|
2575
|
+
function rowAction(el: HTMLElement, root: HTMLElement): HTMLElement | null {
|
|
2576
|
+
const row = listRowOf(el, root);
|
|
2577
|
+
if (!row || row.matches('.sb-button, .sb-navlink, .sb-listrow[data-sb-selectable]')) return null;
|
|
2578
|
+
const buttons = row.querySelectorAll<HTMLElement>('.sb-button');
|
|
2579
|
+
if (buttons.length !== 1) return null;
|
|
2580
|
+
const button = buttons[0]!;
|
|
2581
|
+
if (button.dataset['sbStyle'] === 'plain' || button.closest('[data-sb-disabled]')) return null;
|
|
2582
|
+
if (row.querySelector(ROW_CONTROLS)) return null;
|
|
2583
|
+
return button;
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2513
2586
|
/** A vertical `textfield`: a `<textarea>` the layout sizes to its lines. */
|
|
2514
2587
|
function createMultilineField(): HTMLTextAreaElement {
|
|
2515
2588
|
const el = document.createElement('textarea');
|
|
@@ -3565,7 +3638,8 @@ function applyTextField(input: HTMLInputElement | HTMLTextAreaElement, props: Te
|
|
|
3565
3638
|
// A vertical field is a `<textarea>`, which has no type.
|
|
3566
3639
|
if (input instanceof HTMLInputElement) input.type = props.secure ? 'password' : 'text';
|
|
3567
3640
|
else input.dataset['sbAxis'] = 'vertical';
|
|
3568
|
-
|
|
3641
|
+
const placeholder = props.placeholder ?? '';
|
|
3642
|
+
if (input.placeholder !== placeholder) input.placeholder = placeholder;
|
|
3569
3643
|
input.dataset['sbStyle'] = props.style ?? 'plain';
|
|
3570
3644
|
const text = props.text ?? '';
|
|
3571
3645
|
// Leave the caret alone when Swift echoes the value the field already shows.
|
package/src/styles.css
CHANGED
|
@@ -196,6 +196,14 @@
|
|
|
196
196
|
|
|
197
197
|
.device {
|
|
198
198
|
position: relative;
|
|
199
|
+
/* iOS Safari inflates the text of wide multi-line blocks it judges hard to
|
|
200
|
+
read ("text autosizing"): a wrapped section footer or paragraph would draw
|
|
201
|
+
a size up from the one the engine measured it at and run into what
|
|
202
|
+
follows. page.css pins this on the document in device mode; an island
|
|
203
|
+
(@swiftbrowser/react) lives in a page that may not, so pin it here too.
|
|
204
|
+
Inherited, so it covers everything the frame draws. */
|
|
205
|
+
-webkit-text-size-adjust: 100%;
|
|
206
|
+
text-size-adjust: 100%;
|
|
199
207
|
}
|
|
200
208
|
|
|
201
209
|
/* The iPhone's size and bezel: the frame's only. An island (`.sb-island`,
|
|
@@ -759,6 +767,8 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
759
767
|
color: var(--sb-color-placeholder);
|
|
760
768
|
}
|
|
761
769
|
|
|
770
|
+
/* A UITextField's rounded rect: the same with the focus as without (iOS
|
|
771
|
+
draws no focus ring or tinted border on it). */
|
|
762
772
|
.sb-textfield[data-sb-style="roundedBorder"] {
|
|
763
773
|
padding: 0 7px;
|
|
764
774
|
border: 1px solid var(--sb-color-field-border);
|
|
@@ -766,11 +776,6 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
766
776
|
background: var(--sb-color-field-background);
|
|
767
777
|
}
|
|
768
778
|
|
|
769
|
-
.sb-textfield[data-sb-style="roundedBorder"]:focus-visible {
|
|
770
|
-
border-color: var(--sb-color-accent);
|
|
771
|
-
box-shadow: 0 0 0 2px color-mix(in srgb, var(--sb-color-accent) 30%, transparent);
|
|
772
|
-
}
|
|
773
|
-
|
|
774
779
|
/* Toggle: the label children are positioned by their frames; the switch sits
|
|
775
780
|
at the engine's `switch` rect (51×31 at the trailing edge). */
|
|
776
781
|
.sb-switch {
|
|
@@ -969,6 +974,12 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
969
974
|
background: var(--sb-color-fill);
|
|
970
975
|
}
|
|
971
976
|
|
|
977
|
+
/* A row whose one button it taps (renderer.ts `rowAction`), while pressed. */
|
|
978
|
+
.sb-section-rows > [data-sb-row-pressed],
|
|
979
|
+
.sb-list-content > [data-sb-row-pressed] {
|
|
980
|
+
background: var(--sb-color-fill);
|
|
981
|
+
}
|
|
982
|
+
|
|
972
983
|
/* ---------------------------------------------------------------------------
|
|
973
984
|
Phase 2: navigation
|
|
974
985
|
------------------------------------------------------------------------- */
|
|
@@ -2901,6 +2912,14 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
2901
2912
|
pointer-events: auto;
|
|
2902
2913
|
}
|
|
2903
2914
|
|
|
2915
|
+
/* A list row is content however it is made (a stack, a modifier box): its
|
|
2916
|
+
empty space is the cell's, and a tap there taps the row's one button
|
|
2917
|
+
(renderer.ts `rowAction`), as on iOS. */
|
|
2918
|
+
.sb-section-rows > [data-sb-kind],
|
|
2919
|
+
.sb-list-content > [data-sb-kind] {
|
|
2920
|
+
pointer-events: auto;
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2904
2923
|
.sb-styled[data-sb-background] {
|
|
2905
2924
|
pointer-events: auto;
|
|
2906
2925
|
}
|
|
@@ -3749,14 +3768,17 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
3749
3768
|
}
|
|
3750
3769
|
|
|
3751
3770
|
/* ---------------------------------------------------------------------------
|
|
3752
|
-
Text editor: a borderless textarea filling its frame in
|
|
3753
|
-
parent provides any frame or background).
|
|
3771
|
+
Text editor: a borderless textarea filling its frame in its context's font
|
|
3772
|
+
(layoutDom.ts; its parent provides any frame or background). Its text is
|
|
3773
|
+
inset as a UITextView's is: 8 above and below (`textContainerInset`), 5 at
|
|
3774
|
+
the sides (`lineFragmentPadding`).
|
|
3754
3775
|
------------------------------------------------------------------------- */
|
|
3755
3776
|
.sb-texteditor {
|
|
3756
3777
|
appearance: none;
|
|
3757
3778
|
display: block;
|
|
3779
|
+
box-sizing: border-box;
|
|
3758
3780
|
margin: 0;
|
|
3759
|
-
padding:
|
|
3781
|
+
padding: 8px 5px;
|
|
3760
3782
|
border: 0;
|
|
3761
3783
|
outline: none;
|
|
3762
3784
|
resize: none;
|
package/src/symbols.ts
CHANGED
|
@@ -577,6 +577,20 @@ const QUESTIONMARK: IconNode = [
|
|
|
577
577
|
['path', { d: 'M12 20.5h.01' }],
|
|
578
578
|
];
|
|
579
579
|
|
|
580
|
+
/**
|
|
581
|
+
* `tray.fill`: the tray's silhouette (lucide's `Inbox`) filled, split along
|
|
582
|
+
* its opening's edge, the line with the notch, by a gap: the well above,
|
|
583
|
+
* the front below. A real gap, not a stroke in the background color, so
|
|
584
|
+
* whatever is behind the glyph (a selected tab's capsule) shows through.
|
|
585
|
+
* The pieces' edges along it sit 1.75 from that line, so with their 2 wide
|
|
586
|
+
* stroke they leave 1.5 between them, the notch's slants offset square to
|
|
587
|
+
* their direction.
|
|
588
|
+
*/
|
|
589
|
+
const TRAY_FILL: IconNode = [
|
|
590
|
+
['path', { d: 'M2.88 10.25 5.45 5.11A2 2 0 0 1 7.24 4h9.52a2 2 0 0 1 1.79 1.11l2.57 5.14h-6.06l-2 3h-2.12l-2-3z' }],
|
|
591
|
+
['path', { d: 'M2 13.75h5.06l2 3h5.88l2-3H22V18a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2z' }],
|
|
592
|
+
];
|
|
593
|
+
|
|
580
594
|
const outline = (icon: IconNode): SymbolGlyph => ({ icon, fill: 'outline' });
|
|
581
595
|
const knockout = (icon: IconNode): SymbolGlyph => ({ icon, fill: 'knockout' });
|
|
582
596
|
const solid = (icon: IconNode): SymbolGlyph => ({ icon, fill: 'solid' });
|
|
@@ -1083,7 +1097,7 @@ export const SF_SYMBOLS: Readonly<Record<string, SymbolGlyph>> = {
|
|
|
1083
1097
|
'envelope.badge': outline(MailWarning),
|
|
1084
1098
|
'envelope.open.fill': outline(MailOpen),
|
|
1085
1099
|
'tray.full': outline(Inbox),
|
|
1086
|
-
'tray.fill': solid(
|
|
1100
|
+
'tray.fill': solid(TRAY_FILL),
|
|
1087
1101
|
'tray.and.arrow.down': outline(Inbox),
|
|
1088
1102
|
'tray.and.arrow.up': outline(Inbox),
|
|
1089
1103
|
'phone.arrow.up.right': outline(PhoneOutgoing),
|