@swiftbrowser/web 0.4.0 → 0.5.1

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/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'
@@ -850,7 +860,7 @@ export interface DecoratedProps {
850
860
  export interface MenuProps {
851
861
  deferred?: boolean;
852
862
  }
853
- export type ToolbarPlacement = 'trailing' | 'leading' | 'principal' | 'bottom';
863
+ export type ToolbarPlacement = 'trailing' | 'leading' | 'principal' | 'bottom' | 'secondary';
854
864
  /**
855
865
  * `.toolbar { ToolbarItem(placement:) { ... } }`, an extra child of a
856
866
  * `navscreen` after its content. The children are the items, laid out at
@@ -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;
@@ -397,7 +401,7 @@ export class Renderer {
397
401
  // `closest` picks the innermost control, so a button inside a
398
402
  // `tapgesture` (or a menu label) handles the click alone.
399
403
  const target = (event.target as HTMLElement | null)?.closest<HTMLElement>(
400
- '.sb-button, .sb-navlink, .sb-navbar-back, .sb-navsplit-toggle, .sb-switch, .sb-sheet-scrim, ' +
404
+ '.sb-button, .sb-navlink, .sb-navbar-back, .sb-navbar-more, .sb-navsplit-toggle, .sb-switch, .sb-sheet-scrim, ' +
401
405
  '.sb-tabbar-item, .sb-segment, .sb-picker-option, .sb-picker-menu-item, .sb-picker[data-sb-style="menu"], ' +
402
406
  '.sb-menu, .sb-tapgesture, .sb-stepper-button, .sb-alert-scrim, .sb-alert-ok, ' +
403
407
  // Phase 8: swipe action buttons, the edit-mode row control and selectable rows (the row sends `tap` itself)
@@ -506,6 +510,16 @@ export class Renderer {
506
510
  this.onEvent({ type: 'tap', id });
507
511
  return;
508
512
  }
513
+ if (target.classList.contains('sb-navbar-more')) {
514
+ // The collapsed `secondary` items, as a menu under the button.
515
+ const screen = target.closest<HTMLElement>('.sb-navscreen');
516
+ const id = Number(screen?.dataset[ID_ATTR]);
517
+ if (!screen || !Number.isInteger(id)) return;
518
+ event.preventDefault();
519
+ if (this.menu?.anchorId === id) this.closeMenu();
520
+ else this.openMenu(target, id, this.collapsedSecondaryRows(id));
521
+ return;
522
+ }
509
523
  if (target.classList.contains('sb-navbar-back')) {
510
524
  const id = Number(target.dataset['sbTarget']);
511
525
  if (!Number.isInteger(id)) return;
@@ -835,10 +849,19 @@ export class Renderer {
835
849
 
836
850
  /**
837
851
  * A `host` element's content changed (the page said so, or it resized):
838
- * the hosts are measured again and the tree laid out.
852
+ * the hosts are measured again and the tree laid out. A host whose content
853
+ * was not registered when its element was created (a page that registers
854
+ * from an effect, after its commit) adopts it now.
839
855
  */
840
856
  private hostContentChanged(): void {
841
- if (this.commits === 0 || this.hostContents.size === 0) return;
857
+ if (this.commits === 0) return;
858
+ for (const id of this.hosts) {
859
+ if (this.hostContents.has(id)) continue;
860
+ const el = this.elements.get(id);
861
+ const node = this.nodes.get(id);
862
+ if (el && node) this.adoptHostContent(id, el, node.props as unknown as HostProps);
863
+ }
864
+ if (this.hostContents.size === 0) return;
842
865
  for (const id of this.hostContents.keys()) {
843
866
  const node = this.nodes.get(id);
844
867
  if (node) this.engine?.invalidate(node);
@@ -1173,6 +1196,20 @@ export class Renderer {
1173
1196
  event.y = y;
1174
1197
  event.safeArea = safeAreaOverlap({ x, y, width, height }, env.screen, env.safeArea);
1175
1198
  key += `@${x},${y}/${event.safeArea.top},${event.safeArea.leading},${event.safeArea.bottom},${event.safeArea.trailing}`;
1199
+ // The visible rectangle of the scroll view (or list) the reader is in.
1200
+ const scroller = el?.parentElement?.closest<HTMLElement>('.sb-scrollview, .sb-list');
1201
+ if (scroller) {
1202
+ const view = scroller.getBoundingClientRect();
1203
+ event.scrollView = {
1204
+ x: Math.round(view.left - rootRect.left),
1205
+ y: Math.round(view.top - rootRect.top),
1206
+ width: Math.round(view.width),
1207
+ height: Math.round(view.height),
1208
+ axis: scroller.dataset['sbAxes'] === 'horizontal' ? 'horizontal' : 'vertical',
1209
+ };
1210
+ const v = event.scrollView;
1211
+ key += `/${v.x},${v.y},${v.width},${v.height},${v.axis}`;
1212
+ }
1176
1213
  }
1177
1214
  const last = this.reportedGeometry.get(id);
1178
1215
  if (last && last.key === key) continue;
@@ -1225,6 +1262,21 @@ export class Renderer {
1225
1262
  * and navlinks become tap rows, pickers a checked section, dividers and
1226
1263
  * sections separators and captions, texts disabled captions.
1227
1264
  */
1265
+ /**
1266
+ * A navscreen's More menu: its `secondary` toolbar items the engine
1267
+ * collapsed (those it gave no frame), in order.
1268
+ */
1269
+ private collapsedSecondaryRows(id: ElementId): MenuRow[] {
1270
+ const screen = this.nodes.get(id);
1271
+ if (!screen) return [];
1272
+ const frames = this.layoutResult?.frames;
1273
+ const items = screen.children
1274
+ .filter((child) => child.kind === 'toolbar' && child.props['placement'] === 'secondary')
1275
+ .flatMap((toolbar) => toolbar.children)
1276
+ .filter((item) => !frames?.has(item.id));
1277
+ return collectMenuRows(items);
1278
+ }
1279
+
1228
1280
  private menuRows(id: ElementId): MenuRow[] {
1229
1281
  const node = this.nodes.get(id);
1230
1282
  return node ? collectMenuRows(node.children.slice(1)) : [];
@@ -1386,7 +1438,10 @@ export class Renderer {
1386
1438
  applyProps(el, kind, props, this.assets);
1387
1439
  this.noteScroll(id, kind, props);
1388
1440
  if (kind === 'lazyspacer') this.lazySpacers.add(id);
1389
- if (kind === 'host') this.adoptHostContent(id, el, props as HostProps);
1441
+ if (kind === 'host') {
1442
+ this.hosts.add(id);
1443
+ this.adoptHostContent(id, el, props as HostProps);
1444
+ }
1390
1445
  }
1391
1446
 
1392
1447
  /** A `host` element takes the page's content element (src/hostContent.ts) and watches its size. */
@@ -1699,6 +1754,7 @@ export class Renderer {
1699
1754
  this.hostObserver?.unobserve(content);
1700
1755
  this.hostContents.delete(id);
1701
1756
  }
1757
+ this.hosts.delete(id);
1702
1758
  this.elements.delete(id);
1703
1759
  this.boxes.delete(id);
1704
1760
  this.dirty.delete(id);
@@ -1982,7 +2038,16 @@ function createShell(kind: ElementKind): HTMLElement {
1982
2038
  if (chevron) back.append(chevron);
1983
2039
  back.append(span('sb-navbar-back-label'));
1984
2040
  const inlineTitle = span('sb-navbar-title');
1985
- bar.append(back, inlineTitle, div('sb-navbar-trailing'));
2041
+ // The More button of the collapsed `secondary` items (chrome.more).
2042
+ const more = document.createElement('button');
2043
+ more.type = 'button';
2044
+ more.className = 'sb-navbar-more';
2045
+ more.setAttribute('aria-label', 'More');
2046
+ more.setAttribute('aria-haspopup', 'menu');
2047
+ more.hidden = true;
2048
+ const ellipsis = renderSymbol('ellipsis');
2049
+ if (ellipsis) more.append(ellipsis);
2050
+ bar.append(back, inlineTitle, div('sb-navbar-trailing'), more);
1986
2051
  const body = div('sb-navscreen-body');
1987
2052
  const slot = div('sb-slot sb-navscreen-content');
1988
2053
  body.append(div('sb-navbar-large-title'), slot);
@@ -2737,8 +2802,11 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
2737
2802
  }
2738
2803
  case 'toolbar': {
2739
2804
  const placement = (props as ToolbarProps).placement;
2805
+ // `secondary` items shown in the bar look like the trailing ones (glass capsules).
2740
2806
  el.dataset['sbPlacement'] =
2741
2807
  placement === 'leading' || placement === 'principal' || placement === 'bottom' ? placement : 'trailing';
2808
+ // A `ToolbarSpacer`: draws nothing.
2809
+ el.toggleAttribute('data-sb-spacer', typeof (props as ToolbarProps).spacer === 'string');
2742
2810
  break;
2743
2811
  }
2744
2812
  case 'searchfield':
@@ -2825,6 +2893,12 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
2825
2893
  el.dataset['sbColumns'] = String(Array.isArray(grid.columns) ? grid.columns.length : 0);
2826
2894
  break;
2827
2895
  }
2896
+ case 'lazyhgrid': {
2897
+ const grid = props as LazyHGridProps;
2898
+ el.dataset['sbAlignment'] = grid.alignment ?? 'center';
2899
+ el.dataset['sbRows'] = String(Array.isArray(grid.rows) ? grid.rows.length : 0);
2900
+ break;
2901
+ }
2828
2902
  case 'gauge':
2829
2903
  applyGauge(el, props as GaugeProps);
2830
2904
  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
- * (styles.css). The top edge's effect is the bar's own (largeTitle.ts).
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 under = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop > 1;
67
- setValue(owner, 'sbScrolledBottom', under ? '' : null);
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
  }