@swiftbrowser/web 0.4.0 → 0.5.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 +1 -0
- package/app/index.html +1 -0
- package/package.json +2 -1
- package/src/largeTitle.ts +4 -1
- package/src/layout/engine.ts +167 -47
- package/src/layout/index.ts +3 -3
- package/src/layoutDom.ts +21 -0
- package/src/page.css +234 -0
- package/src/protocol.ts +31 -0
- package/src/renderer.ts +42 -3
- package/src/scrollEdge.ts +26 -4
- package/src/styles.css +61 -252
- package/src/symbols.ts +3 -1
package/src/page.css
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/* ---------------------------------------------------------------------------
|
|
2
|
+
The device page (app/index.html): the page around the iPhone frame, with
|
|
3
|
+
its toolbar, its stage and its error banner. Not the renderer's: a page
|
|
4
|
+
that draws an island (@swiftbrowser/react's `SwiftUIView`) imports
|
|
5
|
+
styles.css alone and keeps its own layout.
|
|
6
|
+
------------------------------------------------------------------------- */
|
|
7
|
+
|
|
8
|
+
:root {
|
|
9
|
+
--page-bg: #e9e9ee;
|
|
10
|
+
--page-fg: #1c1c1e;
|
|
11
|
+
--page-muted: #6e6e73;
|
|
12
|
+
--page-border: rgba(0, 0, 0, 0.08);
|
|
13
|
+
--page-surface: rgba(255, 255, 255, 0.8);
|
|
14
|
+
--page-error-bg: #ffe5e5;
|
|
15
|
+
--page-error-fg: #a1000d;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@media (prefers-color-scheme: dark) {
|
|
19
|
+
:root {
|
|
20
|
+
--page-bg: #111114;
|
|
21
|
+
--page-fg: #f2f2f7;
|
|
22
|
+
--page-muted: #98989f;
|
|
23
|
+
--page-border: rgba(255, 255, 255, 0.1);
|
|
24
|
+
--page-surface: rgba(28, 28, 30, 0.85);
|
|
25
|
+
--page-error-bg: #3a0b10;
|
|
26
|
+
--page-error-fg: #ff8a8a;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
*,
|
|
31
|
+
*::before,
|
|
32
|
+
*::after {
|
|
33
|
+
box-sizing: border-box;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
html,
|
|
37
|
+
body {
|
|
38
|
+
margin: 0;
|
|
39
|
+
min-height: 100%;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
body {
|
|
43
|
+
display: flex;
|
|
44
|
+
flex-direction: column;
|
|
45
|
+
min-height: 100vh;
|
|
46
|
+
background: var(--page-bg);
|
|
47
|
+
color: var(--page-fg);
|
|
48
|
+
font-family: var(--sb-font-family);
|
|
49
|
+
font-size: 14px;
|
|
50
|
+
-webkit-font-smoothing: antialiased;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.toolbar {
|
|
54
|
+
position: sticky;
|
|
55
|
+
top: 0;
|
|
56
|
+
z-index: 10;
|
|
57
|
+
display: flex;
|
|
58
|
+
align-items: center;
|
|
59
|
+
gap: 10px;
|
|
60
|
+
padding: 10px 16px;
|
|
61
|
+
background: var(--page-surface);
|
|
62
|
+
border-bottom: 1px solid var(--page-border);
|
|
63
|
+
backdrop-filter: blur(16px);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.toolbar-brand {
|
|
67
|
+
font-weight: 600;
|
|
68
|
+
letter-spacing: -0.01em;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.toolbar-sep {
|
|
72
|
+
width: 1px;
|
|
73
|
+
height: 18px;
|
|
74
|
+
background: var(--page-border);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.toolbar-label {
|
|
78
|
+
color: var(--page-muted);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.toolbar-value {
|
|
82
|
+
font-variant-numeric: tabular-nums;
|
|
83
|
+
padding: 2px 8px;
|
|
84
|
+
border-radius: 6px;
|
|
85
|
+
background: var(--page-border);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.toolbar-spacer {
|
|
89
|
+
flex: 1 1 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* Hot-reload indicator, driven by the dev server's sb:build-* events (the
|
|
93
|
+
`sb-spin` keyframes are styles.css's, loaded with this file). */
|
|
94
|
+
.toolbar-build {
|
|
95
|
+
display: inline-flex;
|
|
96
|
+
align-items: center;
|
|
97
|
+
gap: 6px;
|
|
98
|
+
color: var(--page-muted);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.toolbar-build[hidden] {
|
|
102
|
+
display: none;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.toolbar-build::before {
|
|
106
|
+
content: "";
|
|
107
|
+
width: 10px;
|
|
108
|
+
height: 10px;
|
|
109
|
+
border-radius: 50%;
|
|
110
|
+
border: 1.5px solid currentColor;
|
|
111
|
+
border-top-color: transparent;
|
|
112
|
+
animation: sb-spin 0.9s linear infinite;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.toolbar-build[data-state="error"] {
|
|
116
|
+
color: var(--page-error-fg);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.toolbar-build[data-state="error"]::before {
|
|
120
|
+
animation: none;
|
|
121
|
+
border-color: currentColor;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.toolbar-button {
|
|
125
|
+
font: inherit;
|
|
126
|
+
padding: 6px 12px;
|
|
127
|
+
border: 1px solid var(--page-border);
|
|
128
|
+
border-radius: 8px;
|
|
129
|
+
background: transparent;
|
|
130
|
+
color: var(--page-fg);
|
|
131
|
+
cursor: pointer;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.toolbar-button:hover {
|
|
135
|
+
background: var(--page-border);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.toolbar-button[aria-pressed="true"] {
|
|
139
|
+
background: var(--page-fg);
|
|
140
|
+
color: var(--page-bg);
|
|
141
|
+
border-color: transparent;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.toolbar-select {
|
|
145
|
+
font: inherit;
|
|
146
|
+
padding: 5px 8px;
|
|
147
|
+
border: 1px solid var(--page-border);
|
|
148
|
+
border-radius: 8px;
|
|
149
|
+
background: transparent;
|
|
150
|
+
color: var(--page-fg);
|
|
151
|
+
cursor: pointer;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.stage {
|
|
155
|
+
display: flex;
|
|
156
|
+
flex-direction: column;
|
|
157
|
+
align-items: center;
|
|
158
|
+
gap: 16px;
|
|
159
|
+
padding: 32px 16px 48px;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.status {
|
|
163
|
+
max-width: 560px;
|
|
164
|
+
margin: 0;
|
|
165
|
+
padding: 10px 14px;
|
|
166
|
+
border-radius: 10px;
|
|
167
|
+
background: var(--page-error-bg);
|
|
168
|
+
color: var(--page-error-fg);
|
|
169
|
+
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
|
170
|
+
font-size: 12px;
|
|
171
|
+
white-space: pre-wrap;
|
|
172
|
+
word-break: break-word;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* ---------------------------------------------------------------------------
|
|
176
|
+
Device mode (html[data-sb-mode="device"], see src/deviceMode.ts): no
|
|
177
|
+
toolbar, no stage padding, the page behind the app in the OS appearance's
|
|
178
|
+
system background from the first paint (Safari tints its status bar strip
|
|
179
|
+
from it; chromeColor.ts then keeps it at the color the app draws at its
|
|
180
|
+
top edge), and the error banner floating clear of the home indicator.
|
|
181
|
+
------------------------------------------------------------------------- */
|
|
182
|
+
|
|
183
|
+
html[data-sb-mode="device"] {
|
|
184
|
+
height: 100%;
|
|
185
|
+
overflow: hidden;
|
|
186
|
+
overscroll-behavior: none;
|
|
187
|
+
/* No double-tap zoom, no text inflation in landscape. */
|
|
188
|
+
touch-action: manipulation;
|
|
189
|
+
-webkit-text-size-adjust: 100%;
|
|
190
|
+
text-size-adjust: 100%;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
html[data-sb-mode="device"] body {
|
|
194
|
+
min-height: 100dvh;
|
|
195
|
+
overflow: hidden;
|
|
196
|
+
overscroll-behavior: none;
|
|
197
|
+
touch-action: manipulation;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/* The page behind the app is the OS appearance's system background from the
|
|
201
|
+
first paint (Safari tints its status bar strip from it); chromeColor.ts
|
|
202
|
+
then keeps it at the color the app draws at its top edge. */
|
|
203
|
+
html[data-sb-mode="device"],
|
|
204
|
+
html[data-sb-mode="device"] body {
|
|
205
|
+
background: #ffffff;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
@media (prefers-color-scheme: dark) {
|
|
209
|
+
html[data-sb-mode="device"],
|
|
210
|
+
html[data-sb-mode="device"] body {
|
|
211
|
+
background: #000000;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
html[data-sb-mode="device"] .toolbar {
|
|
216
|
+
display: none;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
html[data-sb-mode="device"] .stage {
|
|
220
|
+
padding: 0;
|
|
221
|
+
gap: 0;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/* The error banner floats over the app, clear of the home indicator. */
|
|
225
|
+
html[data-sb-mode="device"] .status {
|
|
226
|
+
position: fixed;
|
|
227
|
+
left: 16px;
|
|
228
|
+
right: 16px;
|
|
229
|
+
bottom: calc(16px + var(--sb-inset-bottom));
|
|
230
|
+
z-index: 10;
|
|
231
|
+
max-width: none;
|
|
232
|
+
max-height: 40dvh;
|
|
233
|
+
overflow: auto;
|
|
234
|
+
}
|
package/src/protocol.ts
CHANGED
|
@@ -803,6 +803,16 @@ export interface LazyVGridProps {
|
|
|
803
803
|
spacing: number | null;
|
|
804
804
|
alignment: FrameAlignment;
|
|
805
805
|
}
|
|
806
|
+
/**
|
|
807
|
+
* `LazyHGrid`: `LazyVGrid` turned on its side. The `rows` share the height
|
|
808
|
+
* the grid is proposed; the cells fill them column by column, `spacing`
|
|
809
|
+
* (default 8) apart. Children are the cells (flattened). Nothing is lazy.
|
|
810
|
+
*/
|
|
811
|
+
export interface LazyHGridProps {
|
|
812
|
+
rows: GridItem[];
|
|
813
|
+
spacing: number | null;
|
|
814
|
+
alignment: FrameAlignment;
|
|
815
|
+
}
|
|
806
816
|
export type GaugeStyle =
|
|
807
817
|
| 'automatic'
|
|
808
818
|
| 'linearCapacity'
|
|
@@ -859,6 +869,12 @@ export type ToolbarPlacement = 'trailing' | 'leading' | 'principal' | 'bottom';
|
|
|
859
869
|
*/
|
|
860
870
|
export interface ToolbarProps {
|
|
861
871
|
placement: ToolbarPlacement;
|
|
872
|
+
/**
|
|
873
|
+
* A `ToolbarSpacer` (no children): it separates glass groups, and in a
|
|
874
|
+
* bottom bar flexible ones share the room left over and push the groups
|
|
875
|
+
* apart; fixed ones are 8 wide.
|
|
876
|
+
*/
|
|
877
|
+
spacer?: 'flexible' | 'fixed';
|
|
862
878
|
}
|
|
863
879
|
/**
|
|
864
880
|
* `.searchable(text:)`, an extra child of a `navscreen`: a 36pt rounded
|
|
@@ -920,6 +936,7 @@ export interface ElementPropsByKind {
|
|
|
920
936
|
grid: GridProps;
|
|
921
937
|
gridrow: GridRowProps;
|
|
922
938
|
lazyvgrid: LazyVGridProps;
|
|
939
|
+
lazyhgrid: LazyHGridProps;
|
|
923
940
|
gauge: GaugeProps;
|
|
924
941
|
decorated: DecoratedProps;
|
|
925
942
|
menu: MenuProps;
|
|
@@ -1316,6 +1333,11 @@ export interface GeometryEvent {
|
|
|
1316
1333
|
y?: number;
|
|
1317
1334
|
/** With `reportsFrame`: how far the device's safe area reaches into the box on each edge. */
|
|
1318
1335
|
safeArea?: { top: number; leading: number; bottom: number; trailing: number };
|
|
1336
|
+
/**
|
|
1337
|
+
* With `reportsFrame`, inside a scroll view or list: its visible rectangle
|
|
1338
|
+
* in the root's coordinates (whole px) and the axis it scrolls along.
|
|
1339
|
+
*/
|
|
1340
|
+
scrollView?: { x: number; y: number; width: number; height: number; axis: 'horizontal' | 'vertical' };
|
|
1319
1341
|
}
|
|
1320
1342
|
|
|
1321
1343
|
/** Host measurements of one subview of a `layout` element (points, 2 decimals; `"infinity"` for an unbounded axis). */
|
|
@@ -2161,6 +2183,15 @@ export interface Style {
|
|
|
2161
2183
|
contentTransition?: { kind: 'numericText' | 'opacity' | 'interpolate'; countsDown?: boolean };
|
|
2162
2184
|
}
|
|
2163
2185
|
|
|
2186
|
+
export interface Style {
|
|
2187
|
+
/**
|
|
2188
|
+
* `.containerRelativeFrame(_:…)`: on the marked axes the box is
|
|
2189
|
+
* `a × container + b` long, the container being the nearest scroll view's
|
|
2190
|
+
* visible area (or the screen), and its content is aligned in that frame.
|
|
2191
|
+
*/
|
|
2192
|
+
containerRelativeFrame?: { horizontal?: boolean; vertical?: boolean; a: number; b: number; alignment: FrameAlignment };
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2164
2195
|
/**
|
|
2165
2196
|
* A mouse (or pen) entered (`value: true`) or left a box whose style has
|
|
2166
2197
|
* `hover`. Touch never hovers.
|
package/src/renderer.ts
CHANGED
|
@@ -86,6 +86,7 @@ import {
|
|
|
86
86
|
type HStackProps,
|
|
87
87
|
type LayoutEvent,
|
|
88
88
|
type LazyVGridProps,
|
|
89
|
+
type LazyHGridProps,
|
|
89
90
|
type ImageProps,
|
|
90
91
|
type InspectorProps,
|
|
91
92
|
type ListProps,
|
|
@@ -223,6 +224,7 @@ const ALL_KINDS: ReadonlySet<string> = new Set<ElementKind>([
|
|
|
223
224
|
'grid',
|
|
224
225
|
'gridrow',
|
|
225
226
|
'lazyvgrid',
|
|
227
|
+
'lazyhgrid',
|
|
226
228
|
'gauge',
|
|
227
229
|
'decorated',
|
|
228
230
|
'menu',
|
|
@@ -335,6 +337,8 @@ export class Renderer {
|
|
|
335
337
|
/** A frame check of the `reportsFrame` readers is queued for the next animation frame (after a scroll). */
|
|
336
338
|
private frameCheckQueued = false;
|
|
337
339
|
private readonly hostContents = new Map<ElementId, HTMLElement>();
|
|
340
|
+
/** The `host` elements, adopted or still waiting for their content. */
|
|
341
|
+
private readonly hosts = new Set<ElementId>();
|
|
338
342
|
private readonly hostSizes = new WeakMap<HTMLElement, { width: number; height: number }>();
|
|
339
343
|
private readonly hostMeasures = new Map<string, { width: number; height: number }>();
|
|
340
344
|
private readonly stopHostChanges: () => void;
|
|
@@ -835,10 +839,19 @@ export class Renderer {
|
|
|
835
839
|
|
|
836
840
|
/**
|
|
837
841
|
* A `host` element's content changed (the page said so, or it resized):
|
|
838
|
-
* the hosts are measured again and the tree laid out.
|
|
842
|
+
* the hosts are measured again and the tree laid out. A host whose content
|
|
843
|
+
* was not registered when its element was created (a page that registers
|
|
844
|
+
* from an effect, after its commit) adopts it now.
|
|
839
845
|
*/
|
|
840
846
|
private hostContentChanged(): void {
|
|
841
|
-
if (this.commits === 0
|
|
847
|
+
if (this.commits === 0) return;
|
|
848
|
+
for (const id of this.hosts) {
|
|
849
|
+
if (this.hostContents.has(id)) continue;
|
|
850
|
+
const el = this.elements.get(id);
|
|
851
|
+
const node = this.nodes.get(id);
|
|
852
|
+
if (el && node) this.adoptHostContent(id, el, node.props as unknown as HostProps);
|
|
853
|
+
}
|
|
854
|
+
if (this.hostContents.size === 0) return;
|
|
842
855
|
for (const id of this.hostContents.keys()) {
|
|
843
856
|
const node = this.nodes.get(id);
|
|
844
857
|
if (node) this.engine?.invalidate(node);
|
|
@@ -1173,6 +1186,20 @@ export class Renderer {
|
|
|
1173
1186
|
event.y = y;
|
|
1174
1187
|
event.safeArea = safeAreaOverlap({ x, y, width, height }, env.screen, env.safeArea);
|
|
1175
1188
|
key += `@${x},${y}/${event.safeArea.top},${event.safeArea.leading},${event.safeArea.bottom},${event.safeArea.trailing}`;
|
|
1189
|
+
// The visible rectangle of the scroll view (or list) the reader is in.
|
|
1190
|
+
const scroller = el?.parentElement?.closest<HTMLElement>('.sb-scrollview, .sb-list');
|
|
1191
|
+
if (scroller) {
|
|
1192
|
+
const view = scroller.getBoundingClientRect();
|
|
1193
|
+
event.scrollView = {
|
|
1194
|
+
x: Math.round(view.left - rootRect.left),
|
|
1195
|
+
y: Math.round(view.top - rootRect.top),
|
|
1196
|
+
width: Math.round(view.width),
|
|
1197
|
+
height: Math.round(view.height),
|
|
1198
|
+
axis: scroller.dataset['sbAxes'] === 'horizontal' ? 'horizontal' : 'vertical',
|
|
1199
|
+
};
|
|
1200
|
+
const v = event.scrollView;
|
|
1201
|
+
key += `/${v.x},${v.y},${v.width},${v.height},${v.axis}`;
|
|
1202
|
+
}
|
|
1176
1203
|
}
|
|
1177
1204
|
const last = this.reportedGeometry.get(id);
|
|
1178
1205
|
if (last && last.key === key) continue;
|
|
@@ -1386,7 +1413,10 @@ export class Renderer {
|
|
|
1386
1413
|
applyProps(el, kind, props, this.assets);
|
|
1387
1414
|
this.noteScroll(id, kind, props);
|
|
1388
1415
|
if (kind === 'lazyspacer') this.lazySpacers.add(id);
|
|
1389
|
-
if (kind === 'host')
|
|
1416
|
+
if (kind === 'host') {
|
|
1417
|
+
this.hosts.add(id);
|
|
1418
|
+
this.adoptHostContent(id, el, props as HostProps);
|
|
1419
|
+
}
|
|
1390
1420
|
}
|
|
1391
1421
|
|
|
1392
1422
|
/** A `host` element takes the page's content element (src/hostContent.ts) and watches its size. */
|
|
@@ -1699,6 +1729,7 @@ export class Renderer {
|
|
|
1699
1729
|
this.hostObserver?.unobserve(content);
|
|
1700
1730
|
this.hostContents.delete(id);
|
|
1701
1731
|
}
|
|
1732
|
+
this.hosts.delete(id);
|
|
1702
1733
|
this.elements.delete(id);
|
|
1703
1734
|
this.boxes.delete(id);
|
|
1704
1735
|
this.dirty.delete(id);
|
|
@@ -2739,6 +2770,8 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
|
|
|
2739
2770
|
const placement = (props as ToolbarProps).placement;
|
|
2740
2771
|
el.dataset['sbPlacement'] =
|
|
2741
2772
|
placement === 'leading' || placement === 'principal' || placement === 'bottom' ? placement : 'trailing';
|
|
2773
|
+
// A `ToolbarSpacer`: draws nothing.
|
|
2774
|
+
el.toggleAttribute('data-sb-spacer', typeof (props as ToolbarProps).spacer === 'string');
|
|
2742
2775
|
break;
|
|
2743
2776
|
}
|
|
2744
2777
|
case 'searchfield':
|
|
@@ -2825,6 +2858,12 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
|
|
|
2825
2858
|
el.dataset['sbColumns'] = String(Array.isArray(grid.columns) ? grid.columns.length : 0);
|
|
2826
2859
|
break;
|
|
2827
2860
|
}
|
|
2861
|
+
case 'lazyhgrid': {
|
|
2862
|
+
const grid = props as LazyHGridProps;
|
|
2863
|
+
el.dataset['sbAlignment'] = grid.alignment ?? 'center';
|
|
2864
|
+
el.dataset['sbRows'] = String(Array.isArray(grid.rows) ? grid.rows.length : 0);
|
|
2865
|
+
break;
|
|
2866
|
+
}
|
|
2828
2867
|
case 'gauge':
|
|
2829
2868
|
applyGauge(el, props as GaugeProps);
|
|
2830
2869
|
break;
|
package/src/scrollEdge.ts
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
* has its content inset by the bar (the engine's `bottomInset` chrome rect,
|
|
5
5
|
* layout/engine.ts). While content is under the bar (more of it below) its
|
|
6
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
|
-
*
|
|
7
|
+
* `data-sb-scrolled-bottom` with `--sb-bottom-edge` the bar's height and
|
|
8
|
+
* `--sb-edge-bottom` its strength, which ramps down over the last
|
|
9
|
+
* `EDGE_RAMP` px of content (styles.css). The top edge's effect is the
|
|
10
|
+
* bar's own (largeTitle.ts).
|
|
9
11
|
*
|
|
10
12
|
* `.scrollEdgeEffectStyle(_:for:)` / `.scrollEdgeEffectHidden(_:for:)` reach
|
|
11
13
|
* the scroll view as `edgeEffect` props, which become `data-sb-edge-top` /
|
|
@@ -16,6 +18,24 @@
|
|
|
16
18
|
import type { EdgeEffectProps } from './protocol';
|
|
17
19
|
import type { Rect } from './layout';
|
|
18
20
|
|
|
21
|
+
/**
|
|
22
|
+
* How far content has to be under a bar for its edge effect to be at full
|
|
23
|
+
* strength: the effect ramps up over this scroll distance (and back down as
|
|
24
|
+
* the end comes clear of a bottom bar) rather than switching on, which stood
|
|
25
|
+
* out over content that is not the background color (a photo).
|
|
26
|
+
*/
|
|
27
|
+
export const EDGE_RAMP = 40;
|
|
28
|
+
|
|
29
|
+
/** The strength (0…1) of an edge effect over `distance` px of content under its bar, as a CSS value. */
|
|
30
|
+
export function edgeStrength(distance: number): string {
|
|
31
|
+
return String(Math.round(Math.min(1, Math.max(0, distance / EDGE_RAMP)) * 1000) / 1000);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Sets a CSS custom property, untouched when unchanged. */
|
|
35
|
+
export function setProperty(el: HTMLElement, name: string, value: string): void {
|
|
36
|
+
if (el.style.getPropertyValue(name) !== value) el.style.setProperty(name, value);
|
|
37
|
+
}
|
|
38
|
+
|
|
19
39
|
const bound = new WeakSet<HTMLElement>();
|
|
20
40
|
/** The scroll view whose state each screen's (or tab's) bottom effect shows. */
|
|
21
41
|
const claimedBy = new WeakMap<HTMLElement, HTMLElement>();
|
|
@@ -63,8 +83,9 @@ export function syncScrollEdgeBottom(scroller: HTMLElement): void {
|
|
|
63
83
|
claimedBy.set(owner, scroller);
|
|
64
84
|
// Content is under the bar until the end has scrolled clear of it (the
|
|
65
85
|
// inset is room past the content's end).
|
|
66
|
-
const
|
|
67
|
-
setValue(owner, 'sbScrolledBottom',
|
|
86
|
+
const remaining = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop;
|
|
87
|
+
setValue(owner, 'sbScrolledBottom', remaining > 1 ? '' : null);
|
|
88
|
+
setProperty(owner, '--sb-edge-bottom', edgeStrength(remaining));
|
|
68
89
|
setValue(owner, 'sbEdgeBottom', scroller.dataset['sbEdgeBottom'] ?? null);
|
|
69
90
|
const height = scroller.style.getPropertyValue('--sb-bottom-edge');
|
|
70
91
|
if (owner.style.getPropertyValue('--sb-bottom-edge') !== height) owner.style.setProperty('--sb-bottom-edge', height);
|
|
@@ -73,6 +94,7 @@ export function syncScrollEdgeBottom(scroller: HTMLElement): void {
|
|
|
73
94
|
function release(owner: HTMLElement): void {
|
|
74
95
|
claimedBy.delete(owner);
|
|
75
96
|
setValue(owner, 'sbScrolledBottom', null);
|
|
97
|
+
owner.style.removeProperty('--sb-edge-bottom');
|
|
76
98
|
setValue(owner, 'sbEdgeBottom', null);
|
|
77
99
|
owner.style.removeProperty('--sb-bottom-edge');
|
|
78
100
|
}
|