@swiftbrowser/web 0.9.13 → 0.10.0
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/browserHistory.ts +8 -0
- package/src/layout/engine.ts +81 -9
- package/src/layoutDom.ts +2 -0
- package/src/protocol.ts +23 -0
- package/src/renderer.ts +134 -1
- package/src/scrollTo.ts +11 -1
- package/src/styles.css +35 -1
- package/src/touchPaging.ts +16 -0
package/package.json
CHANGED
package/src/browserHistory.ts
CHANGED
|
@@ -28,6 +28,12 @@
|
|
|
28
28
|
* pen: the browser's edge swipe (Safari's, Android's back gesture) is the
|
|
29
29
|
* swipe back, and it lands here as a `popstate`. A mouse drag from the edge
|
|
30
30
|
* still pops through the renderer, since no browser turns one into history.
|
|
31
|
+
*
|
|
32
|
+
* Every `popstate` the browser makes also starts the renderer's repaint
|
|
33
|
+
* keep-alive (`Renderer.keepRepainting`): Safari's swipe shows a snapshot of
|
|
34
|
+
* the entry and, once its animation ends, hides the live page until a new
|
|
35
|
+
* frame is committed, so a pop that finished animating by then would stay
|
|
36
|
+
* under the snapshot for Safari's 3 s watchdog, taking the touches unseen.
|
|
31
37
|
*/
|
|
32
38
|
import type { Renderer } from './renderer';
|
|
33
39
|
|
|
@@ -317,6 +323,8 @@ export class BrowserHistory {
|
|
|
317
323
|
}
|
|
318
324
|
const target = stateOf(event.state);
|
|
319
325
|
if (!target) return; // an entry of the page's own (before the base, or the app's router): not ours
|
|
326
|
+
// The browser moved on its own (a swipe, a button): keep painting past its transition.
|
|
327
|
+
this.renderer.keepRepainting();
|
|
320
328
|
const depth = this.depth();
|
|
321
329
|
if (target.sbDepth < depth) {
|
|
322
330
|
// Back: dismiss one level now, the rest as each commit lands.
|
package/src/layout/engine.ts
CHANGED
|
@@ -85,6 +85,8 @@ export interface Context {
|
|
|
85
85
|
imageScale: number;
|
|
86
86
|
/** Phase 7: true inside the sidebar column of a `navsplit` (a selected `navlink` row drops its chevron there). */
|
|
87
87
|
splitSidebar: boolean;
|
|
88
|
+
/** Inside a leading or trailing item of a navigation bar (a segmented picker there is the bar's own capsule). */
|
|
89
|
+
navBarItem?: boolean;
|
|
88
90
|
/** Rows of a `sidebar`-style list (iPadOS): its links have no chevron. */
|
|
89
91
|
sidebarList?: boolean;
|
|
90
92
|
/**
|
|
@@ -219,6 +221,17 @@ const TAB_BAR_ITEM_INSET = 4;
|
|
|
219
221
|
const PICKER_SEGMENTED_HEIGHT = 32;
|
|
220
222
|
const PICKER_SEGMENT_PAD = 20;
|
|
221
223
|
const PICKER_SEGMENT_FONT_SIZE = 13;
|
|
224
|
+
// iOS 26, a segmented picker in a navigation bar item (measured in the
|
|
225
|
+
// simulator, Examples/Fidelity `topbarsegmented`, with the item's shared
|
|
226
|
+
// glass hidden): the control is a 44-tall glass capsule itself, no track;
|
|
227
|
+
// the selected segment a 36-tall gray capsule inset 4 (no shadow); labels
|
|
228
|
+
// 15 regular; segments of equal width, the widest label and 21 either side.
|
|
229
|
+
// The capsule keeps 4 clear of the bar's other capsules beyond their usual
|
|
230
|
+
// 12 (it sits 16 from the next one).
|
|
231
|
+
const BAR_SEGMENTED_FONT_SIZE = 15;
|
|
232
|
+
const BAR_SEGMENT_PAD = 42;
|
|
233
|
+
const BAR_SEGMENTED_INSET = 4;
|
|
234
|
+
const BAR_SEGMENTED_MARGIN = 4;
|
|
222
235
|
const PICKER_MENU_ROW_HEIGHT = 44;
|
|
223
236
|
const PICKER_MENU_HEIGHT = 34;
|
|
224
237
|
/** `chevron.up.chevron.down` glyph width plus the gap to the value text. */
|
|
@@ -290,6 +303,7 @@ function isSymbolItem(item: LayoutNode): boolean {
|
|
|
290
303
|
|
|
291
304
|
/** A bar item's slot in its glass capsule. */
|
|
292
305
|
function navSlot(item: LayoutNode, width: number): number {
|
|
306
|
+
if (isSegmentedItem(item)) return width + 2 * BAR_SEGMENTED_MARGIN;
|
|
293
307
|
if (isSymbolItem(item) && width <= NAV_GLASS_SIZE) return NAV_GLASS_SIZE;
|
|
294
308
|
return Math.max(NAV_GLASS_SIZE, width + 2 * NAV_GLASS_PAD_H);
|
|
295
309
|
}
|
|
@@ -521,6 +535,12 @@ function ignoresKeyboard(node: LayoutNode): boolean {
|
|
|
521
535
|
}
|
|
522
536
|
|
|
523
537
|
/** The element under a chain of `styled` wrappers. */
|
|
538
|
+
/** A segmented picker as a navigation bar item (under its styled wrappers): it is a capsule of its own. */
|
|
539
|
+
function isSegmentedItem(item: LayoutNode): boolean {
|
|
540
|
+
const inner = unwrapStyled(item);
|
|
541
|
+
return inner.kind === 'picker' && str(inner.props['style']) === 'segmented';
|
|
542
|
+
}
|
|
543
|
+
|
|
524
544
|
function unwrapStyled(node: LayoutNode): LayoutNode {
|
|
525
545
|
let current = node;
|
|
526
546
|
while (current.kind === 'styled' && current.children.length === 1) {
|
|
@@ -1131,6 +1151,8 @@ interface PickerPlan {
|
|
|
1131
1151
|
valueWidth: number;
|
|
1132
1152
|
/** Inline style: whether a label row precedes the options. */
|
|
1133
1153
|
labelRow: boolean;
|
|
1154
|
+
/** Segmented style in a navigation bar item: the bar's glass capsule, its segments inset. */
|
|
1155
|
+
bar?: boolean;
|
|
1134
1156
|
}
|
|
1135
1157
|
|
|
1136
1158
|
// Phase 6 plans.
|
|
@@ -1550,6 +1572,12 @@ export class LayoutPass {
|
|
|
1550
1572
|
if (ctx.barAbove === undefined && ctx.stackAxis === null && !ctx.listRow) return ctx;
|
|
1551
1573
|
return { ...ctx, barAbove: undefined, stackAxis: null, listRow: false };
|
|
1552
1574
|
}
|
|
1575
|
+
case 'toolbar': {
|
|
1576
|
+
const placement = str(node.props['placement']);
|
|
1577
|
+
const navBarItem = placement === 'leading' || placement === 'trailing';
|
|
1578
|
+
if (ctx.stackAxis === null && !ctx.listRow && ctx.navBarItem === navBarItem) return ctx;
|
|
1579
|
+
return { ...ctx, stackAxis: null, listRow: false, navBarItem };
|
|
1580
|
+
}
|
|
1553
1581
|
case 'gesture':
|
|
1554
1582
|
case 'swipeactions':
|
|
1555
1583
|
case 'contextmenu':
|
|
@@ -2014,6 +2042,23 @@ export class LayoutPass {
|
|
|
2014
2042
|
labelRow,
|
|
2015
2043
|
};
|
|
2016
2044
|
}
|
|
2045
|
+
if (style === 'segmented' && ctx.navBarItem) {
|
|
2046
|
+
const font = makeFont('system', BAR_SEGMENTED_FONT_SIZE, 400);
|
|
2047
|
+
let widest = 0;
|
|
2048
|
+
for (const option of options) widest = Math.max(widest, this.measurer.measureWidth(option, font));
|
|
2049
|
+
const natural = options.length * (widest + BAR_SEGMENT_PAD) + 2 * BAR_SEGMENTED_INSET;
|
|
2050
|
+
return {
|
|
2051
|
+
size: { width: finite(p.width) ? p.width : natural, height: NAV_GLASS_SIZE },
|
|
2052
|
+
style: 'segmented',
|
|
2053
|
+
font,
|
|
2054
|
+
options,
|
|
2055
|
+
selected,
|
|
2056
|
+
labelWidth: 0,
|
|
2057
|
+
valueWidth: 0,
|
|
2058
|
+
labelRow: false,
|
|
2059
|
+
bar: true,
|
|
2060
|
+
};
|
|
2061
|
+
}
|
|
2017
2062
|
if (style === 'segmented') {
|
|
2018
2063
|
const font = makeFont('system', PICKER_SEGMENT_FONT_SIZE, 600);
|
|
2019
2064
|
let natural = 0;
|
|
@@ -2057,9 +2102,14 @@ export class LayoutPass {
|
|
|
2057
2102
|
const chrome: Record<string, Rect> = { ...existing };
|
|
2058
2103
|
if (plan.style === 'segmented') {
|
|
2059
2104
|
const n = Math.max(1, plan.options.length);
|
|
2060
|
-
|
|
2105
|
+
// In a bar the segments sit inside the capsule, inset all round; `bar` (the
|
|
2106
|
+
// control's frame) tells the page to draw it as the bar draws it.
|
|
2107
|
+
const inset = plan.bar ? BAR_SEGMENTED_INSET : 0;
|
|
2108
|
+
if (plan.bar) chrome['bar'] = { x, y, width: size.width, height: size.height };
|
|
2109
|
+
else delete chrome['bar'];
|
|
2110
|
+
const segmentWidth = Math.max(0, size.width - 2 * inset) / n;
|
|
2061
2111
|
for (let i = 0; i < plan.options.length; i++) {
|
|
2062
|
-
chrome[`segment${i}`] = { x: x + i * segmentWidth, y, width: segmentWidth, height: size.height };
|
|
2112
|
+
chrome[`segment${i}`] = { x: x + inset + i * segmentWidth, y: y + inset, width: segmentWidth, height: Math.max(0, size.height - 2 * inset) };
|
|
2063
2113
|
}
|
|
2064
2114
|
} else if (plan.style === 'inline') {
|
|
2065
2115
|
// Rows span the list row when there is one (the hit area and the
|
|
@@ -3448,6 +3498,10 @@ export class LayoutPass {
|
|
|
3448
3498
|
interface Run {
|
|
3449
3499
|
placement: 'leading' | 'trailing';
|
|
3450
3500
|
groups: Group[];
|
|
3501
|
+
/** A segmented picker's own run: the glass is the control, inside its margin. */
|
|
3502
|
+
segmented?: boolean;
|
|
3503
|
+
/** `.sharedBackgroundVisibility(.hidden)`: a run of its own with no glass. */
|
|
3504
|
+
bare?: boolean;
|
|
3451
3505
|
/** The capsule's width: the slots 12 apart, a point past the end slots (none for a lone item). */
|
|
3452
3506
|
width: number;
|
|
3453
3507
|
}
|
|
@@ -3469,6 +3523,20 @@ export class LayoutPass {
|
|
|
3469
3523
|
}
|
|
3470
3524
|
const group = groupOf.get(toolbar);
|
|
3471
3525
|
if (!group) continue;
|
|
3526
|
+
// A segmented picker is a capsule of its own: it neither joins nor is joined (iOS 26
|
|
3527
|
+
// draws its glass, and an item's shared glass around it would show as a second capsule).
|
|
3528
|
+
if (toolbar.children.some(isSegmentedItem)) {
|
|
3529
|
+
delete open[placement];
|
|
3530
|
+
runs.push({ placement, groups: [group], width: 0, segmented: true });
|
|
3531
|
+
continue;
|
|
3532
|
+
}
|
|
3533
|
+
// An item that hides the bar's shared glass stands alone, at its own width.
|
|
3534
|
+
if (str(toolbar.props['sharedBackground']) === 'hidden') {
|
|
3535
|
+
delete open[placement];
|
|
3536
|
+
group.slots = group.sizes.map((s) => s.width);
|
|
3537
|
+
runs.push({ placement, groups: [group], width: 0, bare: true });
|
|
3538
|
+
continue;
|
|
3539
|
+
}
|
|
3472
3540
|
let run = open[placement];
|
|
3473
3541
|
if (!run) {
|
|
3474
3542
|
run = { placement, groups: [], width: 0 };
|
|
@@ -3564,8 +3632,13 @@ export class LayoutPass {
|
|
|
3564
3632
|
// items, in slots 12 apart from `startX`; each toolbar's frame spans its
|
|
3565
3633
|
// own items' slots. Runs of a placement follow their order in the
|
|
3566
3634
|
// content, leading-to-trailing.
|
|
3567
|
-
|
|
3568
|
-
|
|
3635
|
+
// The capsules are numbered as they are drawn (the page reads them until one is missing).
|
|
3636
|
+
const glassCount = { leading: 0, trailing: 0 };
|
|
3637
|
+
const placeRun = (run: Run, startX: number): void => {
|
|
3638
|
+
const margin = run.segmented ? BAR_SEGMENTED_MARGIN : 0;
|
|
3639
|
+
if (!run.bare) {
|
|
3640
|
+
chrome[`${run.placement}Glass${glassCount[run.placement]++}`] = { x: startX + margin, y: rowY, width: Math.max(0, run.width - 2 * margin), height: NAV_BAR_HEIGHT };
|
|
3641
|
+
}
|
|
3569
3642
|
const count = run.groups.reduce((sum, g) => sum + (g.slots?.length ?? 0), 0);
|
|
3570
3643
|
let cursor = startX + (count > 1 ? NAV_RUN_PAD : 0);
|
|
3571
3644
|
for (const group of run.groups) {
|
|
@@ -3581,19 +3654,18 @@ export class LayoutPass {
|
|
|
3581
3654
|
this.setFrame(group.toolbar, first, rowY, { width: last - first, height: NAV_BAR_HEIGHT });
|
|
3582
3655
|
}
|
|
3583
3656
|
};
|
|
3584
|
-
let leadingIndex = 0;
|
|
3585
3657
|
for (const run of runs.filter((r) => r.placement === 'leading')) {
|
|
3586
3658
|
if (run.width === 0) continue;
|
|
3587
|
-
placeRun(run, leadingCursor
|
|
3659
|
+
placeRun(run, leadingCursor);
|
|
3588
3660
|
leadingCursor += run.width + NAV_GROUP_SPACING;
|
|
3589
3661
|
}
|
|
3590
3662
|
const trailingRuns = runs.filter((r) => r.placement === 'trailing' && r.width > 0);
|
|
3591
3663
|
let trailingCursor = trailingEdge - (trailingWidth > 0 ? trailingWidth - NAV_GROUP_SPACING : 0);
|
|
3592
3664
|
const trailingStart = trailingCursor;
|
|
3593
|
-
|
|
3594
|
-
placeRun(run, trailingCursor
|
|
3665
|
+
for (const run of trailingRuns) {
|
|
3666
|
+
placeRun(run, trailingCursor);
|
|
3595
3667
|
trailingCursor += run.width + NAV_GROUP_SPACING;
|
|
3596
|
-
}
|
|
3668
|
+
}
|
|
3597
3669
|
if (trailingRuns.length > 0) trailingEdge = trailingStart - NAV_GROUP_SPACING;
|
|
3598
3670
|
for (const group of groups) {
|
|
3599
3671
|
if (group.placement !== 'principal') continue;
|
package/src/layoutDom.ts
CHANGED
|
@@ -953,6 +953,8 @@ function applyChrome(
|
|
|
953
953
|
case 'picker': {
|
|
954
954
|
setFont(el, result.fonts.get(node.id));
|
|
955
955
|
if (node.props['style'] === 'segmented') {
|
|
956
|
+
// In a navigation bar item the control is the bar's glass capsule (engine `bar`).
|
|
957
|
+
el.toggleAttribute('data-sb-bar', chrome?.['bar'] !== undefined);
|
|
956
958
|
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-segment-track'), frame, box);
|
|
957
959
|
const segments = el.querySelectorAll<HTMLElement>(':scope > .sb-segment');
|
|
958
960
|
segments.forEach((segment, i) => placeChrome(segment, chrome?.[`segment${i}`], box));
|
package/src/protocol.ts
CHANGED
|
@@ -489,6 +489,8 @@ export type ButtonRole = 'destructive' | 'cancel' | null;
|
|
|
489
489
|
export interface ButtonProps {
|
|
490
490
|
style?: ButtonStyle;
|
|
491
491
|
role?: ButtonRole;
|
|
492
|
+
/** An `EditButton`: the edit mode it shows ("Done" / "Edit") and flips. A native renderer draws the platform's edit button from it. */
|
|
493
|
+
editMode?: 'active' | 'inactive';
|
|
492
494
|
}
|
|
493
495
|
export interface StyledProps {
|
|
494
496
|
style: Style;
|
|
@@ -539,6 +541,13 @@ export interface UrlImageProps {
|
|
|
539
541
|
resizable: boolean;
|
|
540
542
|
width: number;
|
|
541
543
|
height: number;
|
|
544
|
+
/**
|
|
545
|
+
* React's opt-in (`AsyncImage webInteractions`): the `<img>` keeps the
|
|
546
|
+
* browser's own image interactions (a long press's Save / Share / Copy on
|
|
547
|
+
* iOS, dragging it out, the right-click menu) instead of being inert as a
|
|
548
|
+
* SwiftUI image is. Swift apps never send it.
|
|
549
|
+
*/
|
|
550
|
+
webInteractions?: boolean;
|
|
542
551
|
}
|
|
543
552
|
/**
|
|
544
553
|
* A `UIImage` / `CGImage` bitmap (`Image(uiImage:)`): a recipe the page draws
|
|
@@ -620,6 +629,14 @@ export interface TextFieldProps {
|
|
|
620
629
|
submit?: boolean;
|
|
621
630
|
/** Phase 12 `.submitLabel`: the return key's label (`enterkeyhint`). */
|
|
622
631
|
submitLabel?: string;
|
|
632
|
+
/** `.keyboardType(_:)`: a `UIKeyboardType` case name (`emailAddress`, `URL`, `numberPad`…), the on-screen keyboard (`inputmode`). */
|
|
633
|
+
keyboard?: string;
|
|
634
|
+
/** `.textContentType(_:)`: a `UITextContentType` name (`emailAddress`, `oneTimeCode`…), what autofill offers (`autocomplete`). */
|
|
635
|
+
contentType?: string;
|
|
636
|
+
/** `.textInputAutocapitalization(_:)` (`autocapitalize`). */
|
|
637
|
+
autocapitalization?: 'never' | 'words' | 'sentences' | 'characters';
|
|
638
|
+
/** `.autocorrectionDisabled()` (`autocorrect="off"`). */
|
|
639
|
+
autocorrectionDisabled?: boolean;
|
|
623
640
|
}
|
|
624
641
|
export interface ToggleProps {
|
|
625
642
|
isOn: boolean;
|
|
@@ -918,6 +935,12 @@ export interface ToolbarProps {
|
|
|
918
935
|
* apart; fixed ones are 8 wide.
|
|
919
936
|
*/
|
|
920
937
|
spacer?: 'flexible' | 'fixed';
|
|
938
|
+
/**
|
|
939
|
+
* `.sharedBackgroundVisibility(.hidden)`: a leading or trailing group of
|
|
940
|
+
* the navigation bar draws no glass of the bar's and is a run of its own
|
|
941
|
+
* (a segmented picker is drawn as its own glass capsule either way).
|
|
942
|
+
*/
|
|
943
|
+
sharedBackground?: 'hidden';
|
|
921
944
|
}
|
|
922
945
|
/**
|
|
923
946
|
* `.searchable(text:)`, an extra child of a `navscreen`: a 36pt rounded
|
package/src/renderer.ts
CHANGED
|
@@ -217,6 +217,16 @@ function dismissMenu(menu: HTMLElement): void {
|
|
|
217
217
|
|
|
218
218
|
/** Duration of the navigation push/pop slide; keep in sync with styles.css (`--sb-nav-easing`). */
|
|
219
219
|
export const NAV_TRANSITION_MS = 400;
|
|
220
|
+
/**
|
|
221
|
+
* How long `keepRepainting` paints after a browser history navigation:
|
|
222
|
+
* through Safari's 3 s snapshot-removal watchdog, with room for a slow
|
|
223
|
+
* device. Measured on an iPhone: painting for 1.5 s (past the swipe
|
|
224
|
+
* animation alone) removed the stall but left a single freeze of the touch
|
|
225
|
+
* in progress at the 3 s mark; 5 s left none.
|
|
226
|
+
*/
|
|
227
|
+
export const REPAINT_KEEPALIVE_MS = 5000;
|
|
228
|
+
/** Between two repaints of the keep-alive: a few frames, so the cost is nothing and the first frame after Safari hides the page is near. */
|
|
229
|
+
const REPAINT_KEEPALIVE_INTERVAL_MS = 50;
|
|
220
230
|
/** Duration of the sheet present/dismiss slide; keep in sync with styles.css. */
|
|
221
231
|
export const SHEET_TRANSITION_MS = 400;
|
|
222
232
|
// Phase 8: the sheet drag (and every other pointer gesture) lives in src/interaction.ts.
|
|
@@ -1294,6 +1304,52 @@ export class Renderer {
|
|
|
1294
1304
|
this.onEvent({ type: 'tap', id });
|
|
1295
1305
|
}
|
|
1296
1306
|
|
|
1307
|
+
/** The repaint keep-alive (`keepRepainting`): its box, timer and deadline while it runs. */
|
|
1308
|
+
private repaintKeepAlive: { el: HTMLElement; timer: number; until: number; on: boolean } | null = null;
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* Keeps the page painting for `duration` ms: a 1 px box in the root
|
|
1312
|
+
* alternates between two near-identical colors every few frames, so each
|
|
1313
|
+
* frame carries a real repaint. Safari's back and forward swipe shows a
|
|
1314
|
+
* snapshot of the entry it goes to and, once its own animation ends, hides
|
|
1315
|
+
* the live page until the page commits a new frame; a pop that has already
|
|
1316
|
+
* finished animating by then leaves nothing to commit, and Safari keeps the
|
|
1317
|
+
* snapshot (a still image, under which the live page takes the touches)
|
|
1318
|
+
* until a 3 s watchdog; and a page that stops painting before that
|
|
1319
|
+
* watchdog has a touch in progress interrupted when it fires, so the
|
|
1320
|
+
* painting runs through it. Called from browserHistory.ts for every
|
|
1321
|
+
* history navigation the browser makes; a call while one runs extends it.
|
|
1322
|
+
*/
|
|
1323
|
+
keepRepainting(duration = REPAINT_KEEPALIVE_MS): void {
|
|
1324
|
+
const until = performance.now() + duration;
|
|
1325
|
+
if (this.repaintKeepAlive) {
|
|
1326
|
+
this.repaintKeepAlive.until = Math.max(this.repaintKeepAlive.until, until);
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
const el = document.createElement('div');
|
|
1330
|
+
el.setAttribute('aria-hidden', 'true');
|
|
1331
|
+
el.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;pointer-events:none;z-index:2147483647;background:rgba(127,127,127,0.02)';
|
|
1332
|
+
this.root.append(el);
|
|
1333
|
+
const state = { el, timer: 0, until, on: false };
|
|
1334
|
+
this.repaintKeepAlive = state;
|
|
1335
|
+
state.timer = window.setInterval(() => {
|
|
1336
|
+
if (performance.now() >= state.until) {
|
|
1337
|
+
this.stopRepainting();
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
state.on = !state.on;
|
|
1341
|
+
el.style.background = state.on ? 'rgba(127,127,127,0.03)' : 'rgba(127,127,127,0.02)';
|
|
1342
|
+
}, REPAINT_KEEPALIVE_INTERVAL_MS);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
private stopRepainting(): void {
|
|
1346
|
+
const state = this.repaintKeepAlive;
|
|
1347
|
+
if (!state) return;
|
|
1348
|
+
this.repaintKeepAlive = null;
|
|
1349
|
+
window.clearInterval(state.timer);
|
|
1350
|
+
state.el.remove();
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1297
1353
|
/**
|
|
1298
1354
|
* Run the layout engine over the current tree and position every element.
|
|
1299
1355
|
* Returns the elements whose box changed. Runs at every `commit`; call it
|
|
@@ -1584,6 +1640,7 @@ export class Renderer {
|
|
|
1584
1640
|
}
|
|
1585
1641
|
|
|
1586
1642
|
dispose(): void {
|
|
1643
|
+
this.stopRepainting();
|
|
1587
1644
|
this.closeMenu();
|
|
1588
1645
|
this.root.removeEventListener('click', this.onClick);
|
|
1589
1646
|
this.root.removeEventListener('pointerdown', this.onRowPress);
|
|
@@ -1875,6 +1932,29 @@ export class Renderer {
|
|
|
1875
1932
|
this.pendingScrolls.push(id);
|
|
1876
1933
|
}
|
|
1877
1934
|
|
|
1935
|
+
/**
|
|
1936
|
+
* `ScrollContext.animateScroll` (src/scrollTo.ts): an animated `scrollTo`
|
|
1937
|
+
* of a snapping scroll view on one axis runs as the touch pager's run (the
|
|
1938
|
+
* snap off until it rests, the last frame writing the offset), held from
|
|
1939
|
+
* reporting its position until then as a finger's drive is, so an app
|
|
1940
|
+
* re-centring its pages on the page it reports does so once, at rest.
|
|
1941
|
+
*/
|
|
1942
|
+
animateScroll(el: HTMLElement, left: number, top: number): boolean {
|
|
1943
|
+
if (el.dataset['sbSnap'] === undefined) return false;
|
|
1944
|
+
const axes = el.dataset['sbAxes'];
|
|
1945
|
+
if (axes !== 'horizontal' && axes !== 'vertical') return false;
|
|
1946
|
+
if (el.dataset['sbReportsPosition'] !== undefined) {
|
|
1947
|
+
this.heldScrolls.add(el);
|
|
1948
|
+
const pending = this.positionTimers.get(el);
|
|
1949
|
+
if (pending !== undefined) {
|
|
1950
|
+
window.clearTimeout(pending);
|
|
1951
|
+
this.positionTimers.delete(el);
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
this.touchPaging.animateTo(el, axes === 'horizontal', axes === 'horizontal' ? left : top);
|
|
1955
|
+
return true;
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1878
1958
|
/** `ScrollViewProxy.scrollTo`: scroll to the targets this pass marked (src/scrollTo.ts). */
|
|
1879
1959
|
private performScrolls(animated: boolean): void {
|
|
1880
1960
|
if (this.pendingScrolls.length === 0) return;
|
|
@@ -3798,8 +3878,12 @@ function applyImage(el: HTMLElement, props: ImageProps, assets: AssetCatalog): v
|
|
|
3798
3878
|
el.dataset['sbUrl'] = props.url;
|
|
3799
3879
|
if (props.resizable) el.dataset['sbResizable'] = '';
|
|
3800
3880
|
else delete el.dataset['sbResizable'];
|
|
3881
|
+
// React's opt-in to the browser's own image interactions (styles.css).
|
|
3882
|
+
const web = props.webInteractions === true;
|
|
3883
|
+
el.toggleAttribute('data-sb-web', web);
|
|
3801
3884
|
const existing = el.firstElementChild;
|
|
3802
3885
|
if (existing instanceof HTMLImageElement) {
|
|
3886
|
+
existing.draggable = web;
|
|
3803
3887
|
if (existing.getAttribute('src') !== props.url && existing.dataset['sbPending'] !== props.url) {
|
|
3804
3888
|
swapImageSource(existing, props.url);
|
|
3805
3889
|
}
|
|
@@ -3808,12 +3892,13 @@ function applyImage(el: HTMLElement, props: ImageProps, assets: AssetCatalog): v
|
|
|
3808
3892
|
const img = document.createElement('img');
|
|
3809
3893
|
img.src = props.url;
|
|
3810
3894
|
img.alt = '';
|
|
3811
|
-
img.draggable =
|
|
3895
|
+
img.draggable = web;
|
|
3812
3896
|
img.decoding = 'async';
|
|
3813
3897
|
found(props.url, img);
|
|
3814
3898
|
return;
|
|
3815
3899
|
}
|
|
3816
3900
|
delete el.dataset['sbUrl'];
|
|
3901
|
+
el.removeAttribute('data-sb-web');
|
|
3817
3902
|
if (isAssetImage(props)) {
|
|
3818
3903
|
const name = props.name ?? '';
|
|
3819
3904
|
delete el.dataset['sbSymbol'];
|
|
@@ -3922,6 +4007,54 @@ function applyTextField(input: HTMLInputElement | HTMLTextAreaElement, props: Te
|
|
|
3922
4007
|
else delete input.dataset['sbSubmit'];
|
|
3923
4008
|
if (typeof props.submitLabel === 'string' && props.submitLabel.length > 0) input.enterKeyHint = props.submitLabel;
|
|
3924
4009
|
else input.removeAttribute('enterkeyhint');
|
|
4010
|
+
applyTextInputHints(input, props);
|
|
4011
|
+
}
|
|
4012
|
+
|
|
4013
|
+
/** `UIKeyboardType` → `inputmode`: the browser's nearest on-screen keyboard. */
|
|
4014
|
+
const INPUT_MODES: Record<string, string> = {
|
|
4015
|
+
emailAddress: 'email',
|
|
4016
|
+
URL: 'url',
|
|
4017
|
+
numberPad: 'numeric',
|
|
4018
|
+
asciiCapableNumberPad: 'numeric',
|
|
4019
|
+
decimalPad: 'decimal',
|
|
4020
|
+
phonePad: 'tel',
|
|
4021
|
+
namePhonePad: 'tel',
|
|
4022
|
+
webSearch: 'search',
|
|
4023
|
+
};
|
|
4024
|
+
|
|
4025
|
+
/** `UITextContentType` → `autocomplete`: what autofill offers. */
|
|
4026
|
+
const AUTOCOMPLETE: Record<string, string> = {
|
|
4027
|
+
name: 'name',
|
|
4028
|
+
givenName: 'given-name',
|
|
4029
|
+
familyName: 'family-name',
|
|
4030
|
+
emailAddress: 'email',
|
|
4031
|
+
telephoneNumber: 'tel',
|
|
4032
|
+
username: 'username',
|
|
4033
|
+
password: 'current-password',
|
|
4034
|
+
newPassword: 'new-password',
|
|
4035
|
+
oneTimeCode: 'one-time-code',
|
|
4036
|
+
URL: 'url',
|
|
4037
|
+
fullStreetAddress: 'street-address',
|
|
4038
|
+
postalCode: 'postal-code',
|
|
4039
|
+
creditCardNumber: 'cc-number',
|
|
4040
|
+
};
|
|
4041
|
+
|
|
4042
|
+
/**
|
|
4043
|
+
* `.keyboardType`, `.textContentType`, `.textInputAutocapitalization` and
|
|
4044
|
+
* `.autocorrectionDisabled` as the input's attributes (docs/ops-protocol.md,
|
|
4045
|
+
* "Text input hints"). Without a content type autofill stays off, as a field
|
|
4046
|
+
* in an app does not offer the page's saved values.
|
|
4047
|
+
*/
|
|
4048
|
+
function applyTextInputHints(input: HTMLInputElement | HTMLTextAreaElement, props: TextFieldProps): void {
|
|
4049
|
+
const mode = props.keyboard ? INPUT_MODES[props.keyboard] : undefined;
|
|
4050
|
+
if (mode) input.inputMode = mode;
|
|
4051
|
+
else input.removeAttribute('inputmode');
|
|
4052
|
+
input.autocomplete = ((props.contentType ? AUTOCOMPLETE[props.contentType] : undefined) ?? 'off') as AutoFill;
|
|
4053
|
+
const capitalize = props.autocapitalization === 'never' ? 'none' : props.autocapitalization;
|
|
4054
|
+
if (capitalize) input.setAttribute('autocapitalize', capitalize);
|
|
4055
|
+
else input.removeAttribute('autocapitalize');
|
|
4056
|
+
if (props.autocorrectionDisabled === true) input.setAttribute('autocorrect', 'off');
|
|
4057
|
+
else input.removeAttribute('autocorrect');
|
|
3925
4058
|
}
|
|
3926
4059
|
|
|
3927
4060
|
/** Phase 12 `.scrollTargetLayout()`: the stack's children snap (styles.css). */
|
package/src/scrollTo.ts
CHANGED
|
@@ -49,6 +49,14 @@ export interface ScrollContext {
|
|
|
49
49
|
nodes: Map<number, LayoutNode>;
|
|
50
50
|
parents: Map<number, number>;
|
|
51
51
|
elements: Map<number, HTMLElement>;
|
|
52
|
+
/**
|
|
53
|
+
* Runs an animated scroll of `el` to the offsets itself, returning false
|
|
54
|
+
* to leave it to the browser's smooth scrolling. The renderer takes the
|
|
55
|
+
* snapping scroll views (`scrollTargetBehavior`): a browser animating a
|
|
56
|
+
* scroll-snapped container may snap it again once the animation ends or
|
|
57
|
+
* the content moves (Safari does), so they run as a finger's page does.
|
|
58
|
+
*/
|
|
59
|
+
animateScroll?: (el: HTMLElement, left: number, top: number) => boolean;
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
function anchorOf(command: ScrollToCommand | undefined): UnitPoint | null {
|
|
@@ -116,7 +124,9 @@ export function scrollToTarget(
|
|
|
116
124
|
changed = true;
|
|
117
125
|
}
|
|
118
126
|
if (changed) {
|
|
119
|
-
if (smooth &&
|
|
127
|
+
if (smooth && ctx.animateScroll?.(el, left, top)) {
|
|
128
|
+
// Run by the context (a snapping scroll view).
|
|
129
|
+
} else if (smooth && typeof el.scrollTo === 'function') {
|
|
120
130
|
el.scrollTo({ left, top, behavior: 'smooth' });
|
|
121
131
|
} else {
|
|
122
132
|
el.scrollLeft = left;
|
package/src/styles.css
CHANGED
|
@@ -666,6 +666,17 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
666
666
|
-webkit-user-select: none;
|
|
667
667
|
}
|
|
668
668
|
|
|
669
|
+
/* React's `AsyncImage webInteractions`: the picture is a web page's image
|
|
670
|
+
again, with the browser's long press (Save / Share / Copy on iOS), drag
|
|
671
|
+
out and right-click menu. */
|
|
672
|
+
.sb-image[data-sb-web] > img {
|
|
673
|
+
pointer-events: auto;
|
|
674
|
+
user-select: auto;
|
|
675
|
+
-webkit-user-select: auto;
|
|
676
|
+
-webkit-touch-callout: default;
|
|
677
|
+
-webkit-user-drag: auto;
|
|
678
|
+
}
|
|
679
|
+
|
|
669
680
|
/* `.interpolation(.none)`: a QR code's modules stay sharp when scaled up. */
|
|
670
681
|
.sb-image[data-sb-interpolation='none'] > img,
|
|
671
682
|
.sb-image[data-sb-interpolation='none'] > canvas {
|
|
@@ -1914,6 +1925,28 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
1914
1925
|
max-width: calc(100% - 8px);
|
|
1915
1926
|
}
|
|
1916
1927
|
|
|
1928
|
+
/* iOS 26: a segmented picker in a navigation bar item is the bar's glass
|
|
1929
|
+
capsule itself (the run's glass behind it, engine `bar`): no track, the
|
|
1930
|
+
labels in the engine's font (15 regular), the selected segment a gray
|
|
1931
|
+
capsule the glass tints, with no shadow, 2 in from its neighbours. */
|
|
1932
|
+
.sb-picker[data-sb-style="segmented"][data-sb-bar] > .sb-segment-track {
|
|
1933
|
+
display: none;
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
.sb-picker[data-sb-style="segmented"][data-sb-bar] > .sb-segment {
|
|
1937
|
+
font: inherit;
|
|
1938
|
+
letter-spacing: normal;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
.sb-picker[data-sb-style="segmented"][data-sb-bar] > .sb-segment::before {
|
|
1942
|
+
inset: 0 2px;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
.sb-picker[data-sb-style="segmented"][data-sb-bar] > .sb-segment[aria-checked="true"]::before {
|
|
1946
|
+
background: var(--sb-glass-selected);
|
|
1947
|
+
box-shadow: none;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1917
1950
|
.sb-segment:focus-visible {
|
|
1918
1951
|
outline: 2px solid var(--sb-color-accent);
|
|
1919
1952
|
outline-offset: -2px;
|
|
@@ -2998,7 +3031,8 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
2998
3031
|
}
|
|
2999
3032
|
|
|
3000
3033
|
:is([data-sb-hidden], [data-sb-disabled], [data-sb-hit-testing="false"], .sb-navscreen--pop, .sb-exit-clone, .sb-sheet--out, .sb-alert--out, .sb-tab[data-sb-tab-hidden]) [data-sb-kind],
|
|
3001
|
-
:is([data-sb-hidden], [data-sb-disabled], [data-sb-hit-testing="false"], .sb-navscreen--pop, .sb-exit-clone, .sb-sheet--out, .sb-alert--out, .sb-tab[data-sb-tab-hidden]) .sb-navbar >
|
|
3034
|
+
:is([data-sb-hidden], [data-sb-disabled], [data-sb-hit-testing="false"], .sb-navscreen--pop, .sb-exit-clone, .sb-sheet--out, .sb-alert--out, .sb-tab[data-sb-tab-hidden]) .sb-navbar > *,
|
|
3035
|
+
:is([data-sb-hidden], [data-sb-disabled], [data-sb-hit-testing="false"], .sb-navscreen--pop, .sb-exit-clone, .sb-sheet--out, .sb-alert--out, .sb-tab[data-sb-tab-hidden]) .sb-image[data-sb-web] > img {
|
|
3002
3036
|
pointer-events: none !important;
|
|
3003
3037
|
}
|
|
3004
3038
|
|
package/src/touchPaging.ts
CHANGED
|
@@ -334,6 +334,22 @@ export class TouchPaging {
|
|
|
334
334
|
this.runTo(el, g.horizontal, offset, destination);
|
|
335
335
|
};
|
|
336
336
|
|
|
337
|
+
/**
|
|
338
|
+
* The app scrolls `el` to `to` with an animation (`withAnimation` around
|
|
339
|
+
* `ScrollViewProxy.scrollTo`): the same eased run a finger's page ends
|
|
340
|
+
* with, the snap off until it rests, the last frame writing `to` itself
|
|
341
|
+
* and `onRest` reporting the page. A browser's own smooth scroll of a
|
|
342
|
+
* snapping container is left to snap it again when it likes; Safari does,
|
|
343
|
+
* after the animation or when the app re-centres its pages.
|
|
344
|
+
*/
|
|
345
|
+
animateTo(el: HTMLElement, horizontal: boolean, to: number): void {
|
|
346
|
+
this.stopRun();
|
|
347
|
+
const max = horizontal ? el.scrollWidth - el.clientWidth : el.scrollHeight - el.clientHeight;
|
|
348
|
+
const target = Math.min(Math.max(0, max), Math.max(0, to));
|
|
349
|
+
el.style.scrollSnapType = 'none';
|
|
350
|
+
this.runTo(el, horizontal, horizontal ? el.scrollLeft : el.scrollTop, target);
|
|
351
|
+
}
|
|
352
|
+
|
|
337
353
|
/** Runs the offset to `to` with an ease-out, then lets the container snap again. */
|
|
338
354
|
private runTo(el: HTMLElement, horizontal: boolean, from: number, to: number): void {
|
|
339
355
|
this.stopRun();
|