@swiftbrowser/web 0.3.0 → 0.4.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/README.md +3 -1
- package/package.json +4 -2
- package/src/animation.ts +154 -3
- package/src/dragDrop.ts +549 -0
- package/src/hostContent.ts +37 -0
- package/src/interaction.ts +128 -22
- package/src/largeTitle.ts +95 -54
- package/src/layout/engine.ts +492 -91
- package/src/layout/index.ts +5 -2
- package/src/layout/measure.ts +2 -0
- package/src/layout/text.ts +6 -3
- package/src/layout/types.ts +16 -1
- package/src/layout/typography.ts +30 -10
- package/src/layoutDom.ts +66 -16
- package/src/pointerEffects.ts +182 -0
- package/src/protocol.ts +188 -9
- package/src/renderer.ts +316 -15
- package/src/scrollEdge.ts +87 -0
- package/src/scrollTo.ts +6 -0
- package/src/styles.css +430 -16
- package/src/typography.ts +5 -2
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* iOS 26's scroll edge effect at the bottom: a list or scroll view that runs
|
|
3
|
+
* down under a tab bar, or under its screen's bottom toolbar or search field,
|
|
4
|
+
* has its content inset by the bar (the engine's `bottomInset` chrome rect,
|
|
5
|
+
* layout/engine.ts). While content is under the bar (more of it below) its
|
|
6
|
+
* screen, or the tab it fills, shows the effect over it, behind the bar:
|
|
7
|
+
* `data-sb-scrolled-bottom` with `--sb-bottom-edge` the bar's height
|
|
8
|
+
* (styles.css). The top edge's effect is the bar's own (largeTitle.ts).
|
|
9
|
+
*
|
|
10
|
+
* `.scrollEdgeEffectStyle(_:for:)` / `.scrollEdgeEffectHidden(_:for:)` reach
|
|
11
|
+
* the scroll view as `edgeEffect` props, which become `data-sb-edge-top` /
|
|
12
|
+
* `data-sb-edge-bottom` on it (`hard` or `hidden`; soft is the default) and
|
|
13
|
+
* are copied to the element that draws that edge's effect.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { EdgeEffectProps } from './protocol';
|
|
17
|
+
import type { Rect } from './layout';
|
|
18
|
+
|
|
19
|
+
const bound = new WeakSet<HTMLElement>();
|
|
20
|
+
/** The scroll view whose state each screen's (or tab's) bottom effect shows. */
|
|
21
|
+
const claimedBy = new WeakMap<HTMLElement, HTMLElement>();
|
|
22
|
+
|
|
23
|
+
/** A list's or scroll view's `edgeEffect` props, as `data-sb-edge-top` / `data-sb-edge-bottom`. */
|
|
24
|
+
export function applyEdgeEffect(el: HTMLElement, effect: EdgeEffectProps | undefined): void {
|
|
25
|
+
setValue(el, 'sbEdgeTop', edgeValue(effect?.top));
|
|
26
|
+
setValue(el, 'sbEdgeBottom', edgeValue(effect?.bottom));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Soft is the default effect; only the others are marked. */
|
|
30
|
+
function edgeValue(value: string | undefined): string | null {
|
|
31
|
+
return value === 'hard' || value === 'hidden' ? value : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The element that draws the bottom effect over a scroll view: its screen, else the tab it fills. */
|
|
35
|
+
function ownerOf(scroller: HTMLElement): HTMLElement | null {
|
|
36
|
+
return scroller.closest<HTMLElement>('.sb-navscreen') ?? scroller.closest<HTMLElement>('.sb-tab');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Called for every list and scroll view the layout reaches with the engine's
|
|
41
|
+
* `bottomInset` rect (absent when no bar covers its bottom).
|
|
42
|
+
*/
|
|
43
|
+
export function bindScrollEdgeBottom(scroller: HTMLElement, covered: Rect | undefined): void {
|
|
44
|
+
const owner = ownerOf(scroller);
|
|
45
|
+
if (covered === undefined || covered.height <= 0) {
|
|
46
|
+
if (scroller.dataset['sbUnderBottom'] !== undefined) delete scroller.dataset['sbUnderBottom'];
|
|
47
|
+
if (owner && claimedBy.get(owner) === scroller) release(owner);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
setValue(scroller, 'sbUnderBottom', '');
|
|
51
|
+
scroller.style.setProperty('--sb-bottom-edge', `${covered.height}px`);
|
|
52
|
+
if (!bound.has(scroller)) {
|
|
53
|
+
bound.add(scroller);
|
|
54
|
+
scroller.addEventListener('scroll', () => syncScrollEdgeBottom(scroller), { passive: true });
|
|
55
|
+
}
|
|
56
|
+
syncScrollEdgeBottom(scroller);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function syncScrollEdgeBottom(scroller: HTMLElement): void {
|
|
60
|
+
if (scroller.dataset['sbUnderBottom'] === undefined) return;
|
|
61
|
+
const owner = ownerOf(scroller);
|
|
62
|
+
if (!owner) return;
|
|
63
|
+
claimedBy.set(owner, scroller);
|
|
64
|
+
// Content is under the bar until the end has scrolled clear of it (the
|
|
65
|
+
// inset is room past the content's end).
|
|
66
|
+
const under = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop > 1;
|
|
67
|
+
setValue(owner, 'sbScrolledBottom', under ? '' : null);
|
|
68
|
+
setValue(owner, 'sbEdgeBottom', scroller.dataset['sbEdgeBottom'] ?? null);
|
|
69
|
+
const height = scroller.style.getPropertyValue('--sb-bottom-edge');
|
|
70
|
+
if (owner.style.getPropertyValue('--sb-bottom-edge') !== height) owner.style.setProperty('--sb-bottom-edge', height);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function release(owner: HTMLElement): void {
|
|
74
|
+
claimedBy.delete(owner);
|
|
75
|
+
setValue(owner, 'sbScrolledBottom', null);
|
|
76
|
+
setValue(owner, 'sbEdgeBottom', null);
|
|
77
|
+
owner.style.removeProperty('--sb-bottom-edge');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Sets a data attribute (by dataset key) to `value`, or removes it for null; untouched when unchanged. */
|
|
81
|
+
export function setValue(el: HTMLElement, key: string, value: string | null): void {
|
|
82
|
+
if (value === null) {
|
|
83
|
+
if (el.dataset[key] !== undefined) delete el.dataset[key];
|
|
84
|
+
} else if (el.dataset[key] !== value) {
|
|
85
|
+
el.dataset[key] = value;
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/scrollTo.ts
CHANGED
|
@@ -95,6 +95,12 @@ export function scrollToTarget(
|
|
|
95
95
|
const viewport: Rect = { ...frame };
|
|
96
96
|
const covered = result.chrome.get(at)?.['bottomInset'];
|
|
97
97
|
if (covered) viewport.height = Math.max(0, covered.y - frame.y);
|
|
98
|
+
// Under its screen's bar the visible area starts below the bar.
|
|
99
|
+
const under = result.chrome.get(at)?.['topInset'];
|
|
100
|
+
if (under) {
|
|
101
|
+
viewport.y += under.height;
|
|
102
|
+
viewport.height = Math.max(0, viewport.height - under.height);
|
|
103
|
+
}
|
|
98
104
|
let left = el.scrollLeft;
|
|
99
105
|
let top = el.scrollTop;
|
|
100
106
|
let changed = false;
|
package/src/styles.css
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
------------------------------------------------------------------------- */
|
|
6
6
|
|
|
7
7
|
:root {
|
|
8
|
+
/* SF Pro's ascender minus its cap height: where a line's capitals start. */
|
|
9
|
+
--sb-cap-inset: 0.2476em;
|
|
8
10
|
/* The UINavigationController push curve: fast start, long settle. */
|
|
9
11
|
--sb-nav-easing: cubic-bezier(0.32, 0.72, 0, 1);
|
|
10
12
|
--sb-font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Inter,
|
|
@@ -701,11 +703,20 @@ html[data-sb-mode="device"] .status {
|
|
|
701
703
|
line-height: 1;
|
|
702
704
|
}
|
|
703
705
|
|
|
706
|
+
/* A symbol fills its box as SF Symbols do: at a text size a circle symbol is
|
|
707
|
+
about 1.28× the font size (21.7 pt at body's 17), and the glyphs here
|
|
708
|
+
occupy 20 of their 24-unit grid (a little more with their stroke), so
|
|
709
|
+
the SVG is 1.43em, centred in the
|
|
710
|
+
line-height box the engine gives the image (the box is unchanged; the
|
|
711
|
+
glyph overflows it a little, as a symbol's bounds do). The stroke, 1.4
|
|
712
|
+
units, is then 1.5 px at body, the weight iOS draws regular symbols in. */
|
|
704
713
|
.sb-image > svg {
|
|
705
714
|
display: block;
|
|
706
|
-
|
|
707
|
-
|
|
715
|
+
flex: none;
|
|
716
|
+
width: 1.43em;
|
|
717
|
+
height: 1.43em;
|
|
708
718
|
stroke: currentColor;
|
|
719
|
+
stroke-width: 1.4;
|
|
709
720
|
}
|
|
710
721
|
|
|
711
722
|
/* An indeterminate ProgressView: the activity indicator (symbols.ts), in
|
|
@@ -949,6 +960,19 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
949
960
|
text-align: left;
|
|
950
961
|
}
|
|
951
962
|
|
|
963
|
+
/* SF Pro's capitals start 0.2476em below the top of a line SwiftUI lays out
|
|
964
|
+
(ascender 0.952em − cap height 0.705em): trim the first line to its cap
|
|
965
|
+
height and put it there, so text the engine places lands where iOS draws
|
|
966
|
+
it, whatever font the page has. */
|
|
967
|
+
@supports (text-box: trim-start cap alphabetic) {
|
|
968
|
+
.sb-text,
|
|
969
|
+
.sb-section-header,
|
|
970
|
+
.sb-section-footer {
|
|
971
|
+
text-box: trim-start cap alphabetic;
|
|
972
|
+
padding-top: var(--sb-cap-inset);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
952
976
|
.sb-section-header[hidden],
|
|
953
977
|
.sb-section-footer[hidden] {
|
|
954
978
|
display: none;
|
|
@@ -1026,6 +1050,23 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
1026
1050
|
pointer-events: none;
|
|
1027
1051
|
}
|
|
1028
1052
|
|
|
1053
|
+
/* A text field row is the <input> (or <textarea>) itself, which has no
|
|
1054
|
+
::after: its separator is a 1px background image in the same place. */
|
|
1055
|
+
.sb-section-rows > :is(input, textarea):not(:last-child),
|
|
1056
|
+
.sb-list-content > :is(input, textarea):not(:last-child):not(:has(+ .sb-section)) {
|
|
1057
|
+
background-image: linear-gradient(var(--sb-color-row-separator), var(--sb-color-row-separator));
|
|
1058
|
+
background-repeat: no-repeat;
|
|
1059
|
+
background-position: var(--sb-separator-leading, 16px) 100%;
|
|
1060
|
+
background-size: calc(100% - var(--sb-separator-leading, 16px) - 16px) 1px;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
.sb-section-rows > :is(input, textarea)[data-sb-separator-bottom="hidden"],
|
|
1064
|
+
.sb-list-content > :is(input, textarea)[data-sb-separator-bottom="hidden"],
|
|
1065
|
+
.sb-section-rows > :is(input, textarea):has(+ [data-sb-separator-top="hidden"]),
|
|
1066
|
+
.sb-list-content > :is(input, textarea):has(+ [data-sb-separator-top="hidden"]) {
|
|
1067
|
+
background-image: none;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1029
1070
|
.sb-section-rows > .sb-button,
|
|
1030
1071
|
.sb-list-content > .sb-button {
|
|
1031
1072
|
border-radius: 0;
|
|
@@ -1242,11 +1283,16 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
1242
1283
|
}
|
|
1243
1284
|
|
|
1244
1285
|
/* The large title scrolls with the screen's root list or scroll view
|
|
1245
|
-
(src/largeTitle.ts moves it); drags on it scroll that view.
|
|
1286
|
+
(src/largeTitle.ts moves it into it); drags on it scroll that view. There
|
|
1287
|
+
it is content, under the bar. */
|
|
1246
1288
|
.sb-navbar-large-title {
|
|
1247
1289
|
pointer-events: none;
|
|
1248
1290
|
}
|
|
1249
1291
|
|
|
1292
|
+
[data-sb-under-bar] > .sb-navbar-large-title {
|
|
1293
|
+
z-index: auto;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1250
1296
|
/* Until the large title has scrolled under the bar, the inline title (whose
|
|
1251
1297
|
rect the engine reports for a title that can collapse) stays hidden. */
|
|
1252
1298
|
.sb-navscreen[data-sb-display="large"] > .sb-navbar > .sb-navbar-title {
|
|
@@ -1258,11 +1304,115 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
1258
1304
|
opacity: 1;
|
|
1259
1305
|
}
|
|
1260
1306
|
|
|
1261
|
-
/* iOS 26's scroll edge effect
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1307
|
+
/* iOS 26's scroll edge effect: a screen's root list or scroll view runs up
|
|
1308
|
+
under the bar (the engine insets its content by the bar), and once content
|
|
1309
|
+
is under it (`data-sb-scrolled`, src/largeTitle.ts) the bar blurs it and
|
|
1310
|
+
fades it into the background toward the top of the screen, a little past
|
|
1311
|
+
the bar's bottom edge. The bar's items stay on their glass. */
|
|
1312
|
+
.sb-navscreen {
|
|
1313
|
+
--sb-edge-color: var(--sb-color-system-background);
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
.sb-navscreen:has(> .sb-navscreen-body .sb-list:is([data-sb-under-bar], [data-sb-under-bottom]):not([data-sb-list-style="plain"])) {
|
|
1317
|
+
--sb-edge-color: var(--sb-color-grouped-background);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
.sb-navbar::before {
|
|
1321
|
+
content: "";
|
|
1322
|
+
position: absolute;
|
|
1323
|
+
inset: 0 0 -20px;
|
|
1324
|
+
z-index: -1;
|
|
1325
|
+
pointer-events: none;
|
|
1326
|
+
opacity: 0;
|
|
1327
|
+
transition: opacity 0.2s ease;
|
|
1328
|
+
background: linear-gradient(
|
|
1329
|
+
to bottom,
|
|
1330
|
+
var(--sb-edge-color) 0%,
|
|
1331
|
+
color-mix(in srgb, var(--sb-edge-color) 75%, transparent) 45%,
|
|
1332
|
+
transparent 100%
|
|
1333
|
+
);
|
|
1334
|
+
-webkit-backdrop-filter: blur(6px);
|
|
1335
|
+
backdrop-filter: blur(6px);
|
|
1336
|
+
-webkit-mask-image: linear-gradient(to bottom, #000 60%, transparent);
|
|
1337
|
+
mask-image: linear-gradient(to bottom, #000 60%, transparent);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
.sb-navscreen[data-sb-scrolled] > .sb-navbar::before {
|
|
1341
|
+
opacity: 1;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/* At the bottom: a list or scroll view runs under the tab bar, or its
|
|
1345
|
+
screen's bottom toolbar or search field (the engine insets its content by
|
|
1346
|
+
the bar, `--sb-bottom-edge` tall), and while content is under it
|
|
1347
|
+
(`data-sb-scrolled-bottom`, src/scrollEdge.ts) its screen (over the
|
|
1348
|
+
screen's content: the body is static, so its pseudo-element is placed in
|
|
1349
|
+
the screen, whose own `::after` dims it under a pushed screen), or the
|
|
1350
|
+
tab it fills, blurs it and fades it into the background toward the bottom
|
|
1351
|
+
of the screen, from a little above the bar. The bars stay on top. */
|
|
1352
|
+
.sb-tab {
|
|
1353
|
+
--sb-edge-color: var(--sb-color-system-background);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
.sb-tab:has(> .sb-list[data-sb-under-bottom]:not([data-sb-list-style="plain"])) {
|
|
1357
|
+
--sb-edge-color: var(--sb-color-grouped-background);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
.sb-navscreen > .sb-navscreen-body::after,
|
|
1361
|
+
.sb-tab::after {
|
|
1362
|
+
content: "";
|
|
1363
|
+
position: absolute;
|
|
1364
|
+
left: 0;
|
|
1365
|
+
right: 0;
|
|
1366
|
+
bottom: 0;
|
|
1367
|
+
height: calc(var(--sb-bottom-edge, 0px) + 20px);
|
|
1368
|
+
z-index: 1;
|
|
1369
|
+
pointer-events: none;
|
|
1370
|
+
opacity: 0;
|
|
1371
|
+
transition: opacity 0.2s ease;
|
|
1372
|
+
background: linear-gradient(
|
|
1373
|
+
to top,
|
|
1374
|
+
var(--sb-edge-color) 0%,
|
|
1375
|
+
color-mix(in srgb, var(--sb-edge-color) 75%, transparent) 45%,
|
|
1376
|
+
transparent 100%
|
|
1377
|
+
);
|
|
1378
|
+
-webkit-backdrop-filter: blur(6px);
|
|
1379
|
+
backdrop-filter: blur(6px);
|
|
1380
|
+
-webkit-mask-image: linear-gradient(to top, #000 60%, transparent);
|
|
1381
|
+
mask-image: linear-gradient(to top, #000 60%, transparent);
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
.sb-navscreen[data-sb-scrolled-bottom] > .sb-navscreen-body::after,
|
|
1385
|
+
.sb-tab[data-sb-scrolled-bottom]::after {
|
|
1386
|
+
opacity: 1;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/* `.scrollEdgeEffectStyle(.hard, for:)`: an opaque backing exactly behind
|
|
1390
|
+
the bar with a dividing line, no fade; `.scrollEdgeEffectHidden`: none. */
|
|
1391
|
+
.sb-navscreen[data-sb-edge-top="hard"] > .sb-navbar::before {
|
|
1392
|
+
inset: 0;
|
|
1393
|
+
background: var(--sb-edge-color);
|
|
1394
|
+
-webkit-backdrop-filter: none;
|
|
1395
|
+
backdrop-filter: none;
|
|
1396
|
+
-webkit-mask-image: none;
|
|
1397
|
+
mask-image: none;
|
|
1398
|
+
border-bottom: 0.5px solid var(--sb-color-separator);
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
.sb-navscreen[data-sb-edge-bottom="hard"] > .sb-navscreen-body::after,
|
|
1402
|
+
.sb-tab[data-sb-edge-bottom="hard"]::after {
|
|
1403
|
+
height: var(--sb-bottom-edge, 0px);
|
|
1404
|
+
background: var(--sb-edge-color);
|
|
1405
|
+
-webkit-backdrop-filter: none;
|
|
1406
|
+
backdrop-filter: none;
|
|
1407
|
+
-webkit-mask-image: none;
|
|
1408
|
+
mask-image: none;
|
|
1409
|
+
border-top: 0.5px solid var(--sb-color-separator);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
.sb-navscreen[data-sb-edge-top="hidden"] > .sb-navbar::before,
|
|
1413
|
+
.sb-navscreen[data-sb-edge-bottom="hidden"] > .sb-navscreen-body::after,
|
|
1414
|
+
.sb-tab[data-sb-edge-bottom="hidden"]::after {
|
|
1415
|
+
display: none;
|
|
1266
1416
|
}
|
|
1267
1417
|
|
|
1268
1418
|
/* NavigationLink: a tappable label. Inside a list it is a row with a chevron
|
|
@@ -1522,6 +1672,27 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
1522
1672
|
background: var(--sb-color-system-background);
|
|
1523
1673
|
}
|
|
1524
1674
|
|
|
1675
|
+
/* `sidebar` (iPadOS; a NavigationSplitView's sidebar list): the rows sit on
|
|
1676
|
+
the list's background, without cards or hairlines, and only a selected
|
|
1677
|
+
row is filled (its rounded fill, below). The engine lays it out like
|
|
1678
|
+
`insetGrouped` with 44pt rows. */
|
|
1679
|
+
.sb-list[data-sb-list-style="sidebar"] .sb-section-rows,
|
|
1680
|
+
.sb-list[data-sb-list-style="sidebar"] .sb-section-rows > [data-sb-id],
|
|
1681
|
+
.sb-list[data-sb-list-style="sidebar"] .sb-section-rows > [data-sb-ghost],
|
|
1682
|
+
.sb-list[data-sb-list-style="sidebar"] > .sb-list-content > :not(.sb-section) {
|
|
1683
|
+
background: transparent;
|
|
1684
|
+
border-radius: 0;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
.sb-list[data-sb-list-style="sidebar"] .sb-section-rows > *::after,
|
|
1688
|
+
.sb-list[data-sb-list-style="sidebar"] > .sb-list-content > :not(.sb-section)::after {
|
|
1689
|
+
display: none;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
.sb-list[data-sb-list-style="sidebar"] :is(.sb-section-rows, .sb-list-content) > :is(input, textarea) {
|
|
1693
|
+
background-image: none;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1525
1696
|
/* ---------------------------------------------------------------------------
|
|
1526
1697
|
Phase 4: tab views. The tabs are stacked in the content slot (every tab has
|
|
1527
1698
|
the tab view's frame); the selected one is visible, the others keep their
|
|
@@ -2135,10 +2306,19 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
2135
2306
|
--sb-color-accent: var(--sb-color-primary);
|
|
2136
2307
|
}
|
|
2137
2308
|
|
|
2138
|
-
/*
|
|
2309
|
+
/* Toolbar items in the bar are laid out in the screen's body, under the bar
|
|
2310
|
+
element (z 1): they stack above it, so its scroll edge effect blurs the
|
|
2311
|
+
content under the bar and not them. */
|
|
2312
|
+
.sb-toolbar:is([data-sb-placement="leading"], [data-sb-placement="trailing"], [data-sb-placement="principal"]) {
|
|
2313
|
+
z-index: 2;
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
/* Their symbols are drawn a little larger and heavier (semibold), as iOS 26
|
|
2317
|
+
draws bar buttons: a plus is 17.7 pt with 2.33 pt bars there. */
|
|
2139
2318
|
.sb-toolbar:is([data-sb-placement="leading"], [data-sb-placement="trailing"]) .sb-image > svg {
|
|
2140
|
-
|
|
2141
|
-
|
|
2319
|
+
width: 1.57em;
|
|
2320
|
+
height: 1.57em;
|
|
2321
|
+
stroke-width: 1.85;
|
|
2142
2322
|
}
|
|
2143
2323
|
|
|
2144
2324
|
.sb-toolbar[data-sb-placement="bottom"] {
|
|
@@ -2237,12 +2417,61 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
2237
2417
|
background: var(--sb-color-system-background);
|
|
2238
2418
|
}
|
|
2239
2419
|
|
|
2420
|
+
/* `.inspector`: the content and a trailing panel (protocol children placed by
|
|
2421
|
+
the engine). The panel draws the hairline before it, in the 1pt gap the
|
|
2422
|
+
engine leaves, so the hairline slides with it. */
|
|
2423
|
+
.sb-inspector {
|
|
2424
|
+
overflow: hidden;
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
.sb-inspectorpanel {
|
|
2428
|
+
background: var(--sb-color-system-background);
|
|
2429
|
+
box-shadow: -1px 0 0 var(--sb-color-separator);
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2240
2432
|
.sb-navsplit-separator {
|
|
2241
2433
|
position: absolute;
|
|
2242
2434
|
background: var(--sb-color-separator);
|
|
2243
2435
|
pointer-events: none;
|
|
2244
2436
|
}
|
|
2245
2437
|
|
|
2438
|
+
/* iPadOS: the sidebar toggle, a 44pt glass circle at the leading edge of
|
|
2439
|
+
the bar row (the sidebar's, or the detail's while the sidebar is hidden),
|
|
2440
|
+
over the columns. */
|
|
2441
|
+
.sb-navsplit-toggle {
|
|
2442
|
+
position: absolute;
|
|
2443
|
+
z-index: 2;
|
|
2444
|
+
appearance: none;
|
|
2445
|
+
display: inline-flex;
|
|
2446
|
+
align-items: center;
|
|
2447
|
+
justify-content: center;
|
|
2448
|
+
margin: 0;
|
|
2449
|
+
padding: 0;
|
|
2450
|
+
border: 0;
|
|
2451
|
+
border-radius: 22px;
|
|
2452
|
+
background: var(--sb-glass-background);
|
|
2453
|
+
box-shadow: var(--sb-glass-shadow);
|
|
2454
|
+
backdrop-filter: blur(12px) saturate(180%);
|
|
2455
|
+
-webkit-backdrop-filter: blur(12px) saturate(180%);
|
|
2456
|
+
color: var(--sb-color-primary);
|
|
2457
|
+
cursor: pointer;
|
|
2458
|
+
touch-action: manipulation;
|
|
2459
|
+
-webkit-tap-highlight-color: transparent;
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
.sb-navsplit-toggle[hidden] {
|
|
2463
|
+
display: none;
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
.sb-navsplit-toggle:active {
|
|
2467
|
+
opacity: 0.3;
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2470
|
+
.sb-navsplit-toggle > svg {
|
|
2471
|
+
width: 22px;
|
|
2472
|
+
height: 22px;
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2246
2475
|
/* A selected `navlink` row (`List(selection:)`): the sidebar-style rounded
|
|
2247
2476
|
fill. The engine omits the chevron inside a navsplit sidebar; elsewhere
|
|
2248
2477
|
the chevron stays. The list-row selectors outrank the per-style row
|
|
@@ -2546,7 +2775,8 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
2546
2775
|
}
|
|
2547
2776
|
|
|
2548
2777
|
/* `listrow`: the edit-mode control at the engine's `editControl` rect (hidden
|
|
2549
|
-
otherwise
|
|
2778
|
+
otherwise; its 22px circle is centred 28 from the card's edge, as on
|
|
2779
|
+
iOS); a selected row reuses the Phase 7 selected-row fill. */
|
|
2550
2780
|
.sb-listrow-control {
|
|
2551
2781
|
appearance: none;
|
|
2552
2782
|
position: absolute;
|
|
@@ -2561,6 +2791,12 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
2561
2791
|
cursor: pointer;
|
|
2562
2792
|
touch-action: manipulation;
|
|
2563
2793
|
-webkit-tap-highlight-color: transparent;
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
/* The controls fade in as the list starts editing (layoutDom marks them
|
|
2797
|
+
while they appear); a row moved in the DOM by a reorder keeps them put. */
|
|
2798
|
+
.sb-listrow-control[data-sb-appear],
|
|
2799
|
+
.sb-listrow-grip[data-sb-appear] {
|
|
2564
2800
|
animation: sb-fade-in 0.25s ease-out both;
|
|
2565
2801
|
}
|
|
2566
2802
|
|
|
@@ -2568,6 +2804,89 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
2568
2804
|
display: none;
|
|
2569
2805
|
}
|
|
2570
2806
|
|
|
2807
|
+
/* `listrow`: the reorder grip of a movable row at the engine's
|
|
2808
|
+
`reorderControl` rect (edit mode only): three gray lines, which a press
|
|
2809
|
+
lifts the row by (src/dragDrop.ts). */
|
|
2810
|
+
.sb-listrow-grip {
|
|
2811
|
+
position: absolute;
|
|
2812
|
+
z-index: 2;
|
|
2813
|
+
display: flex;
|
|
2814
|
+
align-items: center;
|
|
2815
|
+
justify-content: center;
|
|
2816
|
+
color: var(--sb-color-tertiary-label);
|
|
2817
|
+
cursor: grab;
|
|
2818
|
+
touch-action: none;
|
|
2819
|
+
user-select: none;
|
|
2820
|
+
-webkit-user-select: none;
|
|
2821
|
+
-webkit-touch-callout: none;
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
.sb-listrow-grip[hidden] {
|
|
2825
|
+
display: none;
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
.sb-listrow-grip > svg {
|
|
2829
|
+
width: 21px;
|
|
2830
|
+
height: 11px;
|
|
2831
|
+
overflow: visible;
|
|
2832
|
+
stroke: currentColor;
|
|
2833
|
+
stroke-width: 1.33;
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
/* A row being reordered: its neighbours slide out of its way (the
|
|
2837
|
+
transition), it follows the pointer above them with a shadow, and settles
|
|
2838
|
+
into its slot with the same slide. The rows are composited for the whole
|
|
2839
|
+
reorder (`will-change`): promoting one as its slide starts would
|
|
2840
|
+
re-rasterize it, a blink of its text and icons. */
|
|
2841
|
+
[data-sb-reordering] > .sb-listrow[data-sb-movable] {
|
|
2842
|
+
transition: transform 0.3s var(--sb-nav-easing);
|
|
2843
|
+
will-change: transform;
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
.sb-listrow[data-sb-lifting] {
|
|
2847
|
+
z-index: 5;
|
|
2848
|
+
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
|
|
2849
|
+
cursor: grabbing;
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
.sb-listrow[data-sb-lifting]:not([data-sb-settling]) {
|
|
2853
|
+
transition: none;
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
/* `draggable` / `dropdestination`: transparent wrappers; a lifted view's
|
|
2857
|
+
copy follows the pointer over everything, scaled up a little with a
|
|
2858
|
+
shadow, while the original dims; letting go off a destination slides the
|
|
2859
|
+
copy back. A targeted destination shows nothing of its own (the app's
|
|
2860
|
+
`isTargeted` does), only that the copy may drop there. */
|
|
2861
|
+
.sb-draggable,
|
|
2862
|
+
.sb-dropdestination {
|
|
2863
|
+
-webkit-touch-callout: none;
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
.sb-draggable {
|
|
2867
|
+
cursor: grab;
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
.sb-draggable[data-sb-dragging] {
|
|
2871
|
+
opacity: 0.4;
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
.sb-drag-preview {
|
|
2875
|
+
z-index: 60;
|
|
2876
|
+
pointer-events: none;
|
|
2877
|
+
opacity: 0.9;
|
|
2878
|
+
filter: drop-shadow(0 8px 20px rgba(0, 0, 0, 0.25));
|
|
2879
|
+
will-change: transform;
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
.sb-drag-preview[data-sb-settling] {
|
|
2883
|
+
transition: transform 0.3s var(--sb-nav-easing);
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
[data-sb-dragging] .sb-dropdestination {
|
|
2887
|
+
cursor: copy;
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2571
2890
|
.sb-listrow-circle {
|
|
2572
2891
|
display: flex;
|
|
2573
2892
|
align-items: center;
|
|
@@ -2608,6 +2927,14 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
2608
2927
|
|
|
2609
2928
|
.sb-listrow[data-sb-deletable]:not([data-sb-selectable]) .sb-listrow-circle > .sb-listrow-minus {
|
|
2610
2929
|
display: block;
|
|
2930
|
+
transition: transform 0.3s var(--sb-nav-easing);
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2933
|
+
/* While the row shows its Delete button (the minus was tapped, or the row
|
|
2934
|
+
swiped) the minus stands upright, as on iOS; the content, and the grip in
|
|
2935
|
+
it, slid aside. */
|
|
2936
|
+
.sb-listrow[data-sb-swipe-open="trailing"] .sb-listrow-circle > .sb-listrow-minus {
|
|
2937
|
+
transform: rotate(-90deg);
|
|
2611
2938
|
}
|
|
2612
2939
|
|
|
2613
2940
|
.sb-listrow[data-sb-selectable] {
|
|
@@ -2621,6 +2948,26 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
2621
2948
|
border-radius: 10px;
|
|
2622
2949
|
}
|
|
2623
2950
|
|
|
2951
|
+
/* The fill is the row's alone: its swipe content (which inherits the row's
|
|
2952
|
+
background to cover the actions behind it) and a selected link inside it
|
|
2953
|
+
would each stack another translucent layer, a darker pill around the
|
|
2954
|
+
label. */
|
|
2955
|
+
.sb-listrow[data-sb-selected] > .sb-swipe-content {
|
|
2956
|
+
background-color: transparent;
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
.sb-listrow .sb-navlink[data-sb-selected] {
|
|
2960
|
+
background: transparent;
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
/* Like iOS, a selected row's fill has no hairline under it or above it. */
|
|
2964
|
+
.sb-section-rows > [data-sb-selected]::after,
|
|
2965
|
+
.sb-list-content > [data-sb-selected]::after,
|
|
2966
|
+
.sb-section-rows > :has(+ [data-sb-selected])::after,
|
|
2967
|
+
.sb-list-content > :has(+ [data-sb-selected])::after {
|
|
2968
|
+
display: none;
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2624
2971
|
@keyframes sb-fade-in {
|
|
2625
2972
|
from {
|
|
2626
2973
|
opacity: 0;
|
|
@@ -2637,7 +2984,8 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
2637
2984
|
position: absolute;
|
|
2638
2985
|
left: 0;
|
|
2639
2986
|
right: 0;
|
|
2640
|
-
|
|
2987
|
+
/* Under the bar a scroll view's content starts below it. */
|
|
2988
|
+
top: var(--sb-top-inset, 0px);
|
|
2641
2989
|
height: 40px;
|
|
2642
2990
|
display: flex;
|
|
2643
2991
|
align-items: center;
|
|
@@ -2665,17 +3013,20 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
2665
3013
|
}
|
|
2666
3014
|
|
|
2667
3015
|
.sb-list[data-sb-refreshable] > .sb-list-content,
|
|
2668
|
-
.sb-scrollview[data-sb-refreshable] > .sb-scroll-content
|
|
3016
|
+
.sb-scrollview[data-sb-refreshable] > .sb-scroll-content,
|
|
3017
|
+
[data-sb-refreshable] > .sb-navbar-large-title {
|
|
2669
3018
|
transition: transform 0.3s var(--sb-nav-easing);
|
|
2670
3019
|
}
|
|
2671
3020
|
|
|
2672
3021
|
[data-sb-pulling] > .sb-list-content,
|
|
2673
|
-
[data-sb-pulling] > .sb-scroll-content
|
|
3022
|
+
[data-sb-pulling] > .sb-scroll-content,
|
|
3023
|
+
[data-sb-pulling] > .sb-navbar-large-title {
|
|
2674
3024
|
transition: none;
|
|
2675
3025
|
}
|
|
2676
3026
|
|
|
2677
3027
|
[data-sb-refreshing] > .sb-list-content,
|
|
2678
|
-
[data-sb-refreshing] > .sb-scroll-content
|
|
3028
|
+
[data-sb-refreshing] > .sb-scroll-content,
|
|
3029
|
+
[data-sb-refreshing] > .sb-navbar-large-title {
|
|
2679
3030
|
transform: translateY(40px);
|
|
2680
3031
|
}
|
|
2681
3032
|
|
|
@@ -3253,3 +3604,66 @@ svg.sb-path {
|
|
|
3253
3604
|
display: block;
|
|
3254
3605
|
overflow: hidden;
|
|
3255
3606
|
}
|
|
3607
|
+
|
|
3608
|
+
/* ---------------------------------------------------------------------------
|
|
3609
|
+
`host` elements (src/hostContent.ts): the page's own DOM in a box the
|
|
3610
|
+
engine sizes. The content element is positioned at the box's origin and
|
|
3611
|
+
takes its width; in `fill` sizing its height too. The box clips.
|
|
3612
|
+
------------------------------------------------------------------------- */
|
|
3613
|
+
.sb-host {
|
|
3614
|
+
display: block;
|
|
3615
|
+
overflow: hidden;
|
|
3616
|
+
}
|
|
3617
|
+
|
|
3618
|
+
.sb-host > .sb-host-content {
|
|
3619
|
+
position: absolute;
|
|
3620
|
+
left: 0;
|
|
3621
|
+
top: 0;
|
|
3622
|
+
width: 100%;
|
|
3623
|
+
box-sizing: border-box;
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3626
|
+
.sb-host[data-sb-sizing="fill"] > .sb-host-content {
|
|
3627
|
+
height: 100%;
|
|
3628
|
+
}
|
|
3629
|
+
|
|
3630
|
+
/* ---------------------------------------------------------------------------
|
|
3631
|
+
Islands (@swiftbrowser/react's `SwiftUIView`): a SwiftUI root inside a
|
|
3632
|
+
page's own layout, with the device's tokens and none of its frame. The
|
|
3633
|
+
page sizes the island; in `content` fit the renderer sets the root's height
|
|
3634
|
+
from the layout.
|
|
3635
|
+
------------------------------------------------------------------------- */
|
|
3636
|
+
.device.sb-island {
|
|
3637
|
+
width: auto;
|
|
3638
|
+
padding: 0;
|
|
3639
|
+
border-radius: 0;
|
|
3640
|
+
background: none;
|
|
3641
|
+
box-shadow: none;
|
|
3642
|
+
flex: initial;
|
|
3643
|
+
}
|
|
3644
|
+
|
|
3645
|
+
.device.sb-island::before,
|
|
3646
|
+
.device.sb-island::after {
|
|
3647
|
+
display: none;
|
|
3648
|
+
}
|
|
3649
|
+
|
|
3650
|
+
.sb-island .device-screen {
|
|
3651
|
+
width: 100%;
|
|
3652
|
+
height: 100%;
|
|
3653
|
+
border-radius: 0;
|
|
3654
|
+
background: none;
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3657
|
+
.sb-island[data-sb-fit="content"],
|
|
3658
|
+
.sb-island[data-sb-fit="content"] .device-screen,
|
|
3659
|
+
.sb-island[data-sb-fit="content"] .screen {
|
|
3660
|
+
position: relative;
|
|
3661
|
+
inset: auto;
|
|
3662
|
+
height: auto;
|
|
3663
|
+
}
|
|
3664
|
+
|
|
3665
|
+
/* `.onHover` (pointerEffects.ts): the whole box hovers, as SwiftUI uses the
|
|
3666
|
+
view's frame, so a hover box is hit in its empty space too. */
|
|
3667
|
+
.sb-styled[data-sb-hover] {
|
|
3668
|
+
pointer-events: auto;
|
|
3669
|
+
}
|
package/src/typography.ts
CHANGED
|
@@ -22,7 +22,10 @@ export {
|
|
|
22
22
|
DYNAMIC_TYPE_SIZES,
|
|
23
23
|
FONT_FAMILIES,
|
|
24
24
|
FONT_WEIGHTS,
|
|
25
|
-
|
|
25
|
+
LINE_HEIGHT_RATIO,
|
|
26
|
+
LEADINGS,
|
|
27
|
+
leadingFor,
|
|
28
|
+
textHeight,
|
|
26
29
|
TEXT_STYLE_SIZES,
|
|
27
30
|
defaultFont,
|
|
28
31
|
lineHeightFor,
|
|
@@ -70,7 +73,7 @@ export function applyTypeSizeVariables(target: HTMLElement, size: DynamicTypeSiz
|
|
|
70
73
|
for (const style of TEXT_STYLES) {
|
|
71
74
|
const { fontSize, lineHeight } = resolveTextStyle(style, size);
|
|
72
75
|
target.style.setProperty(textStyleVar(style, 'size'), `${fontSize}px`);
|
|
73
|
-
target.style.setProperty(textStyleVar(style, 'lh'), `${lineHeight}px`);
|
|
76
|
+
target.style.setProperty(textStyleVar(style, 'lh'), `${Math.round(lineHeight * 100) / 100}px`);
|
|
74
77
|
target.style.setProperty(`--sb-ts-${style}-scale`, String(relativeScale(style, size)));
|
|
75
78
|
}
|
|
76
79
|
}
|