@swiftbrowser/web 0.18.3 → 0.21.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/app/index.html +11 -7
- package/package.json +1 -1
- package/src/appFiles.ts +12 -3
- package/src/audioEngine.ts +1467 -0
- package/src/chartDom.ts +1 -1
- package/src/deviceFrame.ts +188 -0
- package/src/dragDrop.ts +66 -10
- package/src/files.ts +68 -13
- package/src/hostRequests.ts +53 -3
- package/src/interaction.ts +6 -1
- package/src/languageModel.ts +309 -0
- package/src/layout/engine.ts +128 -48
- package/src/layoutDom.ts +43 -3
- package/src/main.ts +12 -1
- package/src/page.css +65 -0
- package/src/pinnedViews.ts +38 -1
- package/src/protocol.ts +70 -5
- package/src/renderer.ts +299 -53
- package/src/scrollTransition.ts +2 -1
- package/src/styles.css +374 -42
- package/src/styles.ts +13 -15
- package/src/symbols.ts +6 -0
- package/src/viewRegistry.ts +58 -0
package/src/renderer.ts
CHANGED
|
@@ -116,6 +116,7 @@ import {
|
|
|
116
116
|
type ScrollViewProps,
|
|
117
117
|
type SearchFieldProps,
|
|
118
118
|
type SectionProps,
|
|
119
|
+
type DisclosureGroupProps,
|
|
119
120
|
type ShapeProps,
|
|
120
121
|
type SheetProps,
|
|
121
122
|
type SpacerProps,
|
|
@@ -125,6 +126,7 @@ import {
|
|
|
125
126
|
type TabProps,
|
|
126
127
|
type TabViewProps,
|
|
127
128
|
type HostProps,
|
|
129
|
+
type RegisteredProps,
|
|
128
130
|
type TextFieldProps,
|
|
129
131
|
type TextProps,
|
|
130
132
|
type TextRun,
|
|
@@ -170,8 +172,9 @@ import { TouchPaging } from './touchPaging';
|
|
|
170
172
|
import { isScrollVisible } from './scrollVisibility';
|
|
171
173
|
import { applyDefaultScrollAnchor, keepScrollAnchors } from './scrollAnchor';
|
|
172
174
|
import { EMPTY_CATALOG, assetColorVar, type AssetCatalog } from './assets';
|
|
173
|
-
import { HIERARCHY_OPACITY, colorToCSS, effectsCSS, styleToCSS, type PaintCSS } from './styles';
|
|
175
|
+
import { HIERARCHY_OPACITY, colorToCSS, effectsCSS, shadowCSS, styleToCSS, type PaintCSS } from './styles';
|
|
174
176
|
import { hostContent, onHostContentChange } from './hostContent';
|
|
177
|
+
import { type RegisteredViewInstance, ViewRegistry } from './viewRegistry';
|
|
175
178
|
import { HoverTracker, applyPointerAndEffects, safeAreaOverlap } from './pointerEffects';
|
|
176
179
|
import { colorMultiplyFilter } from './colorMultiply';
|
|
177
180
|
import { largeTitleOf, largeTitleTextOf } from './largeTitle';
|
|
@@ -192,6 +195,14 @@ export class RendererError extends Error {
|
|
|
192
195
|
}
|
|
193
196
|
|
|
194
197
|
const KIND_ATTR = 'sbKind';
|
|
198
|
+
/**
|
|
199
|
+
* The kinds that draw what they show themselves: a shadow of a box around one falls on it as a whole (castShadows,
|
|
200
|
+
* styles.css), where a container hands it on to the views inside.
|
|
201
|
+
*/
|
|
202
|
+
const DRAWN_KINDS: ReadonlySet<string> = new Set([
|
|
203
|
+
'text', 'image', 'divider', 'color', 'shape', 'textfield', 'toggle', 'picker', 'gauge', 'searchfield', 'registered', 'chart',
|
|
204
|
+
'stepper', 'slider', 'datepicker', 'texteditor', 'video', 'canvas',
|
|
205
|
+
]);
|
|
195
206
|
const ID_ATTR = 'sbId';
|
|
196
207
|
/** `animationToken` of a `styled` box, kept across `update` to detect changes. */
|
|
197
208
|
const ANIMATION_TOKEN = 'sbAnimationToken';
|
|
@@ -251,6 +262,7 @@ export { SHEET_DISMISS_DRAG } from './interaction';
|
|
|
251
262
|
|
|
252
263
|
const ALL_KINDS: ReadonlySet<string> = new Set<ElementKind>([
|
|
253
264
|
'host',
|
|
265
|
+
'registered',
|
|
254
266
|
'text',
|
|
255
267
|
'vstack',
|
|
256
268
|
'hstack',
|
|
@@ -304,6 +316,8 @@ const ALL_KINDS: ReadonlySet<string> = new Set<ElementKind>([
|
|
|
304
316
|
// A `Section`'s view header / footer
|
|
305
317
|
'sectionheader',
|
|
306
318
|
'sectionfooter',
|
|
319
|
+
// `DisclosureGroup`: its label with a chevron, its content below once expanded
|
|
320
|
+
'disclosuregroup',
|
|
307
321
|
// An `AVPlayer` shown by a view (src/video.ts)
|
|
308
322
|
'video',
|
|
309
323
|
'lazyspacer',
|
|
@@ -320,10 +334,14 @@ const SLIDER_THUMB = 38;
|
|
|
320
334
|
export const PICKER_MENU_WIDTH = 250;
|
|
321
335
|
/** Row height inside the picker menu. */
|
|
322
336
|
export const PICKER_MENU_ROW = 44;
|
|
337
|
+
/** A row with a subtitle under its title (styles.css, `[data-sb-subtitled]`). */
|
|
338
|
+
const MENU_SUBTITLED_ROW = 58;
|
|
323
339
|
/** Height of a section header caption row in the popover (Phase 6). */
|
|
324
|
-
const MENU_CAPTION_HEIGHT =
|
|
325
|
-
/**
|
|
326
|
-
const MENU_SEPARATOR_HEIGHT =
|
|
340
|
+
const MENU_CAPTION_HEIGHT = 30;
|
|
341
|
+
/** A group separator in the popover: a hairline with 9px above and below (iOS 26). */
|
|
342
|
+
const MENU_SEPARATOR_HEIGHT = 19;
|
|
343
|
+
/** The popover's padding above its first row and below its last (iOS 26's inset menu); keep in sync with styles.css. */
|
|
344
|
+
const MENU_PADDING = 10;
|
|
327
345
|
/** Gap between the control and its menu. */
|
|
328
346
|
const PICKER_MENU_GAP = 4;
|
|
329
347
|
/** Minimum distance between the menu and the screen edge. */
|
|
@@ -415,6 +433,8 @@ export class Renderer {
|
|
|
415
433
|
private readonly hostContents = new Map<ElementId, HTMLElement>();
|
|
416
434
|
/** The `host` elements, adopted or still waiting for their content. */
|
|
417
435
|
private readonly hosts = new Set<ElementId>();
|
|
436
|
+
/** The registered views (src/viewRegistry.ts) built for `registered` elements, by element: the name each was built for. */
|
|
437
|
+
private readonly registeredViews = new Map<ElementId, { name: string; instance: RegisteredViewInstance | undefined }>();
|
|
418
438
|
private readonly hostSizes = new WeakMap<HTMLElement, { width: number; height: number }>();
|
|
419
439
|
private readonly hostMeasures = new Map<string, { width: number; height: number }>();
|
|
420
440
|
private readonly stopHostChanges: () => void;
|
|
@@ -433,6 +453,12 @@ export class Renderer {
|
|
|
433
453
|
private readonly pressEffects: PressEffects;
|
|
434
454
|
/** Text, links and files dragged in from outside the page (src/dragDrop.ts). */
|
|
435
455
|
private readonly externalDrops: ExternalDrops;
|
|
456
|
+
/**
|
|
457
|
+
* How a dropped file reaches the app: base64 `data` in the event (a Swift
|
|
458
|
+
* app) or the `File` by reference (a React app in the same page sets
|
|
459
|
+
* `reference`; a dropped folder then comes as its files by path).
|
|
460
|
+
*/
|
|
461
|
+
fileDrops: 'bytes' | 'reference' = 'bytes';
|
|
436
462
|
private readonly pinches: PinchController;
|
|
437
463
|
private readonly touches: TouchesController;
|
|
438
464
|
private readonly touchPaging: TouchPaging;
|
|
@@ -515,16 +541,24 @@ export class Renderer {
|
|
|
515
541
|
// `tapgesture` (or a menu label) handles the click alone. Elsewhere in
|
|
516
542
|
// a list row, the row's one button (`rowAction`).
|
|
517
543
|
const hit = event.target as HTMLElement | null;
|
|
518
|
-
|
|
544
|
+
let target = hit?.closest<HTMLElement>(
|
|
519
545
|
'.sb-button, .sb-navlink, .sb-navbar-back, .sb-navbar-more, .sb-navsplit-toggle, .sb-switch, .sb-sheet-scrim, ' +
|
|
520
546
|
'.sb-toggle[data-sb-toggle-style="button"], .sb-toolbar .sb-toggle:not([data-sb-toggle-style]), ' +
|
|
521
547
|
'.sb-tabbar-item, .sb-segment, .sb-picker-option, .sb-picker-menu-item, .sb-picker[data-sb-style="menu"], ' +
|
|
522
548
|
'.sb-menu, .sb-tapgesture, .sb-stepper-button, .sb-alert-scrim, .sb-alert-ok, ' +
|
|
523
549
|
// Phase 8: swipe action buttons, the edit-mode row control and selectable rows (the row sends `tap` itself)
|
|
524
550
|
'.sb-swipe-action, .sb-listrow-control, .sb-listrow[data-sb-selectable], ' +
|
|
551
|
+
// A disclosure group's label, a collapsible section's header: a `tap` on the element toggles it
|
|
552
|
+
'.sb-disclosure-toggle, .sb-section[data-sb-collapsible] > .sb-section-header, .sb-section[data-sb-collapsible] .sb-sectionheader, ' +
|
|
525
553
|
// A link in a rich text: innermost, so it wins over a tappable row around the text
|
|
526
554
|
'.sb-link',
|
|
527
555
|
) ?? (hit ? rowAction(hit, this.root) : null);
|
|
556
|
+
// A link or button row of a list in edit mode is selected, not followed
|
|
557
|
+
// or pressed: the tap is the row's (its `tag`), as iOS selects the row then.
|
|
558
|
+
if (target?.classList.contains('sb-navlink') || target?.classList.contains('sb-button')) {
|
|
559
|
+
const row = target.closest<HTMLElement>('.sb-listrow[data-sb-selectable]');
|
|
560
|
+
if (row && row.closest('.sb-list')?.hasAttribute('data-sb-editing')) target = row;
|
|
561
|
+
}
|
|
528
562
|
if (!target || !this.root.contains(target)) return;
|
|
529
563
|
if (target.classList.contains('sb-link')) {
|
|
530
564
|
const text = target.closest<HTMLElement>('.sb-text');
|
|
@@ -535,6 +569,19 @@ export class Renderer {
|
|
|
535
569
|
this.onEvent({ type: 'link', id, value: url });
|
|
536
570
|
return;
|
|
537
571
|
}
|
|
572
|
+
const sectionHeader = target.classList.contains('sb-section-header') || target.classList.contains('sb-sectionheader');
|
|
573
|
+
if (target.classList.contains('sb-disclosure-toggle') || sectionHeader) {
|
|
574
|
+
// `DisclosureGroup` and `Section(isExpanded:)`: the tap toggles the
|
|
575
|
+
// element (a section's only in a sidebar-style list, where it collapses).
|
|
576
|
+
const section = sectionHeader;
|
|
577
|
+
const owner = target.closest<HTMLElement>(section ? '.sb-section' : '.sb-disclosuregroup');
|
|
578
|
+
const id = Number(owner?.dataset[ID_ATTR]);
|
|
579
|
+
if (!owner || !Number.isInteger(id)) return;
|
|
580
|
+
if (section && owner.closest<HTMLElement>('.sb-list')?.dataset['sbListStyle'] !== 'sidebar') return;
|
|
581
|
+
event.preventDefault();
|
|
582
|
+
this.onEvent({ type: 'tap', id });
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
538
585
|
if (this.interaction.handleClick(target)) {
|
|
539
586
|
event.preventDefault();
|
|
540
587
|
return;
|
|
@@ -588,7 +635,7 @@ export class Renderer {
|
|
|
588
635
|
return;
|
|
589
636
|
}
|
|
590
637
|
if (target.classList.contains('sb-picker-menu-item')) {
|
|
591
|
-
// A popover row: a picker option (`select`) or a button / navlink (`tap`).
|
|
638
|
+
// A popover row: a picker option (`select`), a toggle, or a button / navlink (`tap`).
|
|
592
639
|
if (!this.menu) return;
|
|
593
640
|
const action = target.dataset['sbAction'];
|
|
594
641
|
const id = Number(target.dataset['sbTarget']);
|
|
@@ -597,6 +644,7 @@ export class Renderer {
|
|
|
597
644
|
event.preventDefault();
|
|
598
645
|
this.closeMenu();
|
|
599
646
|
if (action === 'tap') this.onEvent({ type: 'tap', id });
|
|
647
|
+
else if (action === 'toggle') this.onEvent({ type: 'toggle', id, value: target.getAttribute('aria-checked') !== 'true' });
|
|
600
648
|
else if (Number.isInteger(index)) this.onEvent({ type: 'select', id, value: index });
|
|
601
649
|
return;
|
|
602
650
|
}
|
|
@@ -996,6 +1044,7 @@ export class Renderer {
|
|
|
996
1044
|
root,
|
|
997
1045
|
send: (event) => this.onEvent(event),
|
|
998
1046
|
nodeOf: (id) => this.nodes.get(id),
|
|
1047
|
+
fileDrops: () => this.fileDrops,
|
|
999
1048
|
openContextMenu: (anchor, id) => this.openContextMenu(anchor, id),
|
|
1000
1049
|
revealScreen: (screen) => this.revealScreen(screen),
|
|
1001
1050
|
isMenuOpen: (id) => this.menu?.anchorId === id,
|
|
@@ -1229,6 +1278,7 @@ export class Renderer {
|
|
|
1229
1278
|
this.syncFocus();
|
|
1230
1279
|
// The focus the app moved shows or hides a screen's keyboard bar.
|
|
1231
1280
|
if (this.focusedFieldId() !== focusedBefore && this.hasKeyboardToolbars()) this.layout();
|
|
1281
|
+
this.castShadows();
|
|
1232
1282
|
this.performScrolls(op.animation != null);
|
|
1233
1283
|
this.performOffsetScrolls(op.animation != null);
|
|
1234
1284
|
this.checkLazySpacers();
|
|
@@ -1246,6 +1296,55 @@ export class Renderer {
|
|
|
1246
1296
|
}
|
|
1247
1297
|
}
|
|
1248
1298
|
|
|
1299
|
+
/**
|
|
1300
|
+
* SwiftUI's `.shadow` on a container falls on each view it draws (its texts, shapes, images, controls and
|
|
1301
|
+
* backgrounds), on their own shadows too, and not on the container as a whole unless a `.compositingGroup()` in
|
|
1302
|
+
* between draws the views as one. Each box with a shadow (`data-sb-shadow`) hands its views the shadows to cast:
|
|
1303
|
+
* its own, then those of the boxes around it, up to a compositing group (`--sb-shadow-filter`, which styles.css
|
|
1304
|
+
* gives each drawn view's `filter`, and which a box that clips, masks or composites takes for itself). A box's own
|
|
1305
|
+
* background, painted with its children inside, casts them as `box-shadow` instead.
|
|
1306
|
+
*/
|
|
1307
|
+
private castShadows(): void {
|
|
1308
|
+
const sources = this.root.querySelectorAll<HTMLElement>('[data-sb-shadow]');
|
|
1309
|
+
const cast = new Set(this.root.querySelectorAll<HTMLElement>('[data-sb-shadow-cast]'));
|
|
1310
|
+
if (sources.length === 0 && cast.size === 0) return;
|
|
1311
|
+
const filters = new Map<HTMLElement, { filter: string; box: string }>();
|
|
1312
|
+
// The shadows that reach the views inside `el` from the boxes around it, up to a compositing group or a drawn
|
|
1313
|
+
// view, which takes those around it as one and hands none on.
|
|
1314
|
+
const above = (el: HTMLElement): { filter: string; box: string } => {
|
|
1315
|
+
for (let up = el.parentElement; up && up !== this.root; up = up.parentElement) {
|
|
1316
|
+
if (up.dataset['sbComposite'] !== undefined || DRAWN_KINDS.has(up.dataset[KIND_ATTR] ?? '')) break;
|
|
1317
|
+
const found = filters.get(up);
|
|
1318
|
+
if (found) return found;
|
|
1319
|
+
}
|
|
1320
|
+
return { filter: '', box: '' };
|
|
1321
|
+
};
|
|
1322
|
+
// Document order: a box comes before the boxes inside it.
|
|
1323
|
+
for (const el of sources) {
|
|
1324
|
+
const outer = above(el);
|
|
1325
|
+
const filter = `${el.dataset['sbShadow']} ${outer.filter}`.trim();
|
|
1326
|
+
const box = [el.dataset['sbShadowBox'], outer.box].filter(Boolean).join(', ');
|
|
1327
|
+
filters.set(el, { filter, box });
|
|
1328
|
+
if (el.style.getPropertyValue('--sb-shadow-filter') !== filter) el.style.setProperty('--sb-shadow-filter', filter);
|
|
1329
|
+
}
|
|
1330
|
+
// A background painted on a box (its children inside) casts them as a box-shadow.
|
|
1331
|
+
if (sources.length > 0) {
|
|
1332
|
+
// A box that clips, masks or composites casts them for its background already, with what it holds.
|
|
1333
|
+
for (const el of this.root.querySelectorAll<HTMLElement>('.sb-styled[data-sb-background]:not([data-sb-as-one])')) {
|
|
1334
|
+
const { box } = filters.get(el) ?? above(el);
|
|
1335
|
+
if (!box) continue;
|
|
1336
|
+
if (el.style.boxShadow !== box) el.style.boxShadow = box;
|
|
1337
|
+
el.dataset['sbShadowCast'] = '';
|
|
1338
|
+
cast.delete(el);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
// Those no shadow reaches any more.
|
|
1342
|
+
for (const el of cast) {
|
|
1343
|
+
el.style.boxShadow = '';
|
|
1344
|
+
delete el.dataset['sbShadowCast'];
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1249
1348
|
/**
|
|
1250
1349
|
* `withAnimation(_:completionCriteria:_:completion:)`: the pass's `commit`
|
|
1251
1350
|
* carried a completion serial; once the animations its commit started have
|
|
@@ -1691,6 +1790,11 @@ export class Renderer {
|
|
|
1691
1790
|
this.collectLayouts(result);
|
|
1692
1791
|
if (this.applying === 0) this.flushGeometry();
|
|
1693
1792
|
this.queueVisibilityCheck();
|
|
1793
|
+
// `.statusBarHidden` on a screen that shows: the frame's drawn status bar fades (styles.css).
|
|
1794
|
+
const statusHidden = this.root.querySelector('.sb-navscreen[data-sb-status-hidden]:not([hidden])') !== null;
|
|
1795
|
+
if (document.documentElement.hasAttribute('data-sb-status-hidden') !== statusHidden) {
|
|
1796
|
+
document.documentElement.toggleAttribute('data-sb-status-hidden', statusHidden);
|
|
1797
|
+
}
|
|
1694
1798
|
this.afterLayout?.();
|
|
1695
1799
|
return changes;
|
|
1696
1800
|
}
|
|
@@ -1818,10 +1922,23 @@ export class Renderer {
|
|
|
1818
1922
|
const menu = div('sb-picker-menu');
|
|
1819
1923
|
menu.setAttribute('role', 'menu');
|
|
1820
1924
|
menu.dataset['sbFor'] = String(id);
|
|
1821
|
-
|
|
1925
|
+
// No hairline at either end (a section first or last).
|
|
1926
|
+
while (rows[0]?.kind === 'separator') rows = rows.slice(1);
|
|
1927
|
+
while (rows[rows.length - 1]?.kind === 'separator') rows = rows.slice(0, -1);
|
|
1928
|
+
const checks = rows.some((row) => row.kind === 'select' || row.kind === 'toggle');
|
|
1929
|
+
// iOS indents the whole menu around its check column: section titles start with the plain titles.
|
|
1930
|
+
if (checks) menu.dataset['sbChecks'] = '';
|
|
1931
|
+
let height = 2 * MENU_PADDING;
|
|
1822
1932
|
for (const row of rows) {
|
|
1823
|
-
menu.append(menuRowElement(row));
|
|
1824
|
-
height +=
|
|
1933
|
+
menu.append(menuRowElement(row, checks));
|
|
1934
|
+
height +=
|
|
1935
|
+
row.kind === 'caption'
|
|
1936
|
+
? MENU_CAPTION_HEIGHT
|
|
1937
|
+
: row.kind === 'separator'
|
|
1938
|
+
? MENU_SEPARATOR_HEIGHT
|
|
1939
|
+
: (row.kind === 'tap' || row.kind === 'toggle') && row.subtitle
|
|
1940
|
+
? MENU_SUBTITLED_ROW
|
|
1941
|
+
: PICKER_MENU_ROW;
|
|
1825
1942
|
}
|
|
1826
1943
|
// Anchor to the control: trailing edges aligned, below it when there is
|
|
1827
1944
|
// room, else above; clamped inside the screen. A menu taller than the
|
|
@@ -1832,7 +1949,10 @@ export class Renderer {
|
|
|
1832
1949
|
const width = Math.min(PICKER_MENU_WIDTH, Math.max(0, rootRect.width - 2 * PICKER_MENU_MARGIN));
|
|
1833
1950
|
let left = anchorRect.right - rootRect.left - width;
|
|
1834
1951
|
left = Math.max(PICKER_MENU_MARGIN, Math.min(left, rootRect.width - width - PICKER_MENU_MARGIN));
|
|
1835
|
-
|
|
1952
|
+
// A toolbar button's menu grows out of the button and covers it (iOS 26);
|
|
1953
|
+
// any other control's opens just under it.
|
|
1954
|
+
const inToolbar = anchor.closest('.sb-toolbar') !== null;
|
|
1955
|
+
const belowTop = inToolbar ? anchorRect.top - rootRect.top : anchorRect.bottom - rootRect.top + PICKER_MENU_GAP;
|
|
1836
1956
|
const roomBelow = rootRect.height - PICKER_MENU_MARGIN - belowTop;
|
|
1837
1957
|
const roomAbove = anchorRect.top - rootRect.top - PICKER_MENU_GAP - PICKER_MENU_MARGIN;
|
|
1838
1958
|
const below = height <= roomBelow || (height > roomAbove && roomBelow >= roomAbove);
|
|
@@ -2007,6 +2127,34 @@ export class Renderer {
|
|
|
2007
2127
|
this.hosts.add(id);
|
|
2008
2128
|
this.adoptHostContent(id, el, props as HostProps);
|
|
2009
2129
|
}
|
|
2130
|
+
if (kind === 'registered') this.adoptRegistered(id, el, props as RegisteredProps);
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
/**
|
|
2134
|
+
* A `registered` element's view (src/viewRegistry.ts): built for its name,
|
|
2135
|
+
* given the new props after; a name nothing registered shows itself, as an
|
|
2136
|
+
* unknown kind does. The element's own children are the native view's: on
|
|
2137
|
+
* a page the stylesheet hides them.
|
|
2138
|
+
*/
|
|
2139
|
+
private adoptRegistered(id: ElementId, el: HTMLElement, props: RegisteredProps): void {
|
|
2140
|
+
const values = asRecord(props.props);
|
|
2141
|
+
const current = this.registeredViews.get(id);
|
|
2142
|
+
if (current && current.name === props.name) {
|
|
2143
|
+
current.instance?.update?.(values);
|
|
2144
|
+
return;
|
|
2145
|
+
}
|
|
2146
|
+
current?.instance?.dispose?.();
|
|
2147
|
+
const name = props.name;
|
|
2148
|
+
const instance = ViewRegistry.make(name, { id, props: values, send: (value) => this.onEvent({ type: 'registered', id, name, value }) });
|
|
2149
|
+
this.registeredViews.set(id, { name, instance });
|
|
2150
|
+
let content = instance?.element;
|
|
2151
|
+
if (!content) {
|
|
2152
|
+
content = div('sb-registered-missing');
|
|
2153
|
+
content.textContent = name;
|
|
2154
|
+
}
|
|
2155
|
+
content.classList.add('sb-registered-content');
|
|
2156
|
+
el.querySelector(':scope > .sb-registered-content')?.remove();
|
|
2157
|
+
el.prepend(content);
|
|
2010
2158
|
}
|
|
2011
2159
|
|
|
2012
2160
|
/** A toolbar whose placement changed to or from `largeTitle` moves between its screen's content slot and large title. */
|
|
@@ -2419,6 +2567,7 @@ export class Renderer {
|
|
|
2419
2567
|
this.noteScroll(id, kind, props);
|
|
2420
2568
|
this.noteVisibility(id, kind, props);
|
|
2421
2569
|
if (kind === 'host') this.adoptHostContent(id, el, props as HostProps);
|
|
2570
|
+
if (kind === 'registered') this.adoptRegistered(id, el, props as RegisteredProps);
|
|
2422
2571
|
if (kind === 'toolbar') this.rehomeToolbar(id, el, op);
|
|
2423
2572
|
const newToken = el.dataset[ANIMATION_TOKEN];
|
|
2424
2573
|
const tokenChanged = (previous?.tokenChanged ?? false) || (newToken !== undefined && newToken !== oldToken);
|
|
@@ -2654,6 +2803,8 @@ export class Renderer {
|
|
|
2654
2803
|
this.hostContents.delete(id);
|
|
2655
2804
|
}
|
|
2656
2805
|
this.hosts.delete(id);
|
|
2806
|
+
this.registeredViews.get(id)?.instance?.dispose?.();
|
|
2807
|
+
this.registeredViews.delete(id);
|
|
2657
2808
|
this.elements.delete(id);
|
|
2658
2809
|
this.boxes.delete(id);
|
|
2659
2810
|
this.dirty.delete(id);
|
|
@@ -2684,7 +2835,8 @@ interface OpenMenu {
|
|
|
2684
2835
|
|
|
2685
2836
|
/** One row of a popover menu. */
|
|
2686
2837
|
type MenuRow =
|
|
2687
|
-
| { kind: 'tap'; id: ElementId; title: string; symbol: string | null; destructive: boolean; disabled: boolean }
|
|
2838
|
+
| { kind: 'tap'; id: ElementId; title: string; subtitle: string | null; symbol: string | null; destructive: boolean; disabled: boolean }
|
|
2839
|
+
| { kind: 'toggle'; id: ElementId; title: string; subtitle: string | null; symbol: string | null; isOn: boolean; disabled: boolean }
|
|
2688
2840
|
| { kind: 'select'; id: ElementId; index: number; title: string; checked: boolean }
|
|
2689
2841
|
| { kind: 'caption'; text: string }
|
|
2690
2842
|
| { kind: 'text'; text: string }
|
|
@@ -2704,6 +2856,20 @@ function disabledOf(node: LayoutNode): boolean {
|
|
|
2704
2856
|
return false;
|
|
2705
2857
|
}
|
|
2706
2858
|
|
|
2859
|
+
/**
|
|
2860
|
+
* A menu item's title and subtitle, as SwiftUI reads a menu label: its
|
|
2861
|
+
* first text is the title, any after it the subtitle under it.
|
|
2862
|
+
*/
|
|
2863
|
+
function titlesOf(node: LayoutNode): { title: string; subtitle: string | null } {
|
|
2864
|
+
const texts: string[] = [];
|
|
2865
|
+
const visit = (n: LayoutNode): void => {
|
|
2866
|
+
if (n.kind === 'text' && typeof n.props['text'] === 'string' && n.props['text'].trim().length > 0) texts.push(n.props['text'].trim());
|
|
2867
|
+
n.children.forEach(visit);
|
|
2868
|
+
};
|
|
2869
|
+
visit(node);
|
|
2870
|
+
return { title: texts[0] ?? '', subtitle: texts.length > 1 ? texts.slice(1).join(' ') : null };
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2707
2873
|
/**
|
|
2708
2874
|
* Turn a `menu`'s items into popover rows. A picker's options form their
|
|
2709
2875
|
* own group, separated from neighbouring rows like iOS does; explicit
|
|
@@ -2726,12 +2892,16 @@ function collectMenuRows(items: readonly LayoutNode[], rows: MenuRow[] = []): Me
|
|
|
2726
2892
|
rows.push({
|
|
2727
2893
|
kind: 'tap',
|
|
2728
2894
|
id: base.id,
|
|
2729
|
-
|
|
2895
|
+
...titlesOf(base),
|
|
2730
2896
|
symbol: symbolOf(base),
|
|
2731
2897
|
destructive: base.props['role'] === 'destructive',
|
|
2732
2898
|
disabled: disabledOf(item),
|
|
2733
2899
|
});
|
|
2734
2900
|
break;
|
|
2901
|
+
case 'toggle':
|
|
2902
|
+
// A toggle in a menu is a checkable row (SwiftUI's), its label the title.
|
|
2903
|
+
rows.push({ kind: 'toggle', id: base.id, ...titlesOf(base), symbol: symbolOf(base), isOn: base.props['isOn'] === true, disabled: disabledOf(item) });
|
|
2904
|
+
break;
|
|
2735
2905
|
case 'picker': {
|
|
2736
2906
|
const raw = base.props['options'];
|
|
2737
2907
|
const options = Array.isArray(raw) ? raw.map(String) : [];
|
|
@@ -2745,9 +2915,12 @@ function collectMenuRows(items: readonly LayoutNode[], rows: MenuRow[] = []): Me
|
|
|
2745
2915
|
separate();
|
|
2746
2916
|
break;
|
|
2747
2917
|
case 'section': {
|
|
2918
|
+
// An inline section, as iOS draws one: a hairline before and after it, its header a caption.
|
|
2919
|
+
separate();
|
|
2748
2920
|
const header = base.props['header'];
|
|
2749
2921
|
if (typeof header === 'string' && header.length > 0) rows.push({ kind: 'caption', text: header });
|
|
2750
2922
|
collectMenuRows(base.children, rows);
|
|
2923
|
+
pendingSeparator = true;
|
|
2751
2924
|
break;
|
|
2752
2925
|
}
|
|
2753
2926
|
case 'sectionheader': {
|
|
@@ -2777,44 +2950,60 @@ function collectMenuRows(items: readonly LayoutNode[], rows: MenuRow[] = []): Me
|
|
|
2777
2950
|
return rows;
|
|
2778
2951
|
}
|
|
2779
2952
|
|
|
2780
|
-
/**
|
|
2781
|
-
|
|
2953
|
+
/**
|
|
2954
|
+
* Build the DOM for one popover row (the renderer's click handler reads the
|
|
2955
|
+
* data attributes). In a menu with checkable rows (`checks`), every item row
|
|
2956
|
+
* keeps a leading column for the checkmark, as iOS does, so titles line up;
|
|
2957
|
+
* an item's symbol follows the check, its subtitle sits under its title.
|
|
2958
|
+
*/
|
|
2959
|
+
function menuRowElement(row: MenuRow, checks: boolean): HTMLElement {
|
|
2782
2960
|
switch (row.kind) {
|
|
2783
|
-
case 'select':
|
|
2784
|
-
|
|
2785
|
-
item.type = 'button';
|
|
2786
|
-
item.className = 'sb-picker-menu-item';
|
|
2787
|
-
item.setAttribute('role', 'menuitemradio');
|
|
2788
|
-
item.setAttribute('aria-checked', String(row.checked));
|
|
2789
|
-
item.dataset['sbAction'] = 'select';
|
|
2790
|
-
item.dataset['sbTarget'] = String(row.id);
|
|
2791
|
-
item.dataset['sbIndex'] = String(row.index);
|
|
2792
|
-
const check = span('sb-picker-menu-check');
|
|
2793
|
-
const glyph = renderSymbol('checkmark');
|
|
2794
|
-
if (glyph) check.append(glyph);
|
|
2795
|
-
const title = span('sb-picker-menu-title');
|
|
2796
|
-
title.textContent = row.title;
|
|
2797
|
-
item.append(check, title);
|
|
2798
|
-
return item;
|
|
2799
|
-
}
|
|
2961
|
+
case 'select':
|
|
2962
|
+
case 'toggle':
|
|
2800
2963
|
case 'tap': {
|
|
2801
2964
|
const item = document.createElement('button');
|
|
2802
2965
|
item.type = 'button';
|
|
2803
2966
|
item.className = 'sb-picker-menu-item';
|
|
2804
|
-
item.setAttribute('role', 'menuitem');
|
|
2805
|
-
item.dataset['sbAction'] = 'tap';
|
|
2806
2967
|
item.dataset['sbTarget'] = String(row.id);
|
|
2807
|
-
|
|
2808
|
-
if (row.
|
|
2968
|
+
item.dataset['sbAction'] = row.kind;
|
|
2969
|
+
if (row.kind === 'select') {
|
|
2970
|
+
item.setAttribute('role', 'menuitemradio');
|
|
2971
|
+
item.setAttribute('aria-checked', String(row.checked));
|
|
2972
|
+
item.dataset['sbIndex'] = String(row.index);
|
|
2973
|
+
} else if (row.kind === 'toggle') {
|
|
2974
|
+
item.setAttribute('role', 'menuitemcheckbox');
|
|
2975
|
+
item.setAttribute('aria-checked', String(row.isOn));
|
|
2976
|
+
} else {
|
|
2977
|
+
item.setAttribute('role', 'menuitem');
|
|
2978
|
+
if (row.destructive) item.dataset['sbRole'] = 'destructive';
|
|
2979
|
+
}
|
|
2980
|
+
if (row.kind !== 'select' && row.disabled) item.disabled = true;
|
|
2981
|
+
if (checks) {
|
|
2982
|
+
const check = span('sb-picker-menu-check');
|
|
2983
|
+
const glyph = renderSymbol('checkmark');
|
|
2984
|
+
if (glyph) check.append(glyph);
|
|
2985
|
+
item.append(check);
|
|
2986
|
+
}
|
|
2987
|
+
const symbol = row.kind === 'select' ? null : row.symbol;
|
|
2988
|
+
if (symbol) {
|
|
2989
|
+
const holder = span('sb-picker-menu-symbol');
|
|
2990
|
+
const glyph = renderSymbol(symbol);
|
|
2991
|
+
if (glyph) holder.append(glyph);
|
|
2992
|
+
holder.dataset['sbSymbol'] = symbol;
|
|
2993
|
+
item.append(holder);
|
|
2994
|
+
}
|
|
2809
2995
|
const title = span('sb-picker-menu-title');
|
|
2810
2996
|
title.textContent = row.title;
|
|
2811
|
-
|
|
2812
|
-
if (
|
|
2813
|
-
const
|
|
2814
|
-
const
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
item.append(
|
|
2997
|
+
const subtitle = row.kind === 'select' ? null : row.subtitle;
|
|
2998
|
+
if (subtitle) {
|
|
2999
|
+
const titles = span('sb-picker-menu-titles');
|
|
3000
|
+
const sub = span('sb-picker-menu-subtitle');
|
|
3001
|
+
sub.textContent = subtitle;
|
|
3002
|
+
titles.append(title, sub);
|
|
3003
|
+
item.append(titles);
|
|
3004
|
+
item.dataset['sbSubtitled'] = '';
|
|
3005
|
+
} else {
|
|
3006
|
+
item.append(title);
|
|
2818
3007
|
}
|
|
2819
3008
|
return item;
|
|
2820
3009
|
}
|
|
@@ -2837,6 +3026,11 @@ function menuRowElement(row: MenuRow): HTMLElement {
|
|
|
2837
3026
|
}
|
|
2838
3027
|
}
|
|
2839
3028
|
|
|
3029
|
+
/** Whether scrolling dismisses the keyboard; a page cannot drag it down, so `interactively` blurs as `immediately` does. */
|
|
3030
|
+
function dismissesKeyboard(mode: unknown): boolean {
|
|
3031
|
+
return mode === true || mode === 'immediately' || mode === 'interactively';
|
|
3032
|
+
}
|
|
3033
|
+
|
|
2840
3034
|
/** Props as the engine reads them (untrusted JSON, so a plain record). */
|
|
2841
3035
|
function asRecord(props: unknown): Record<string, unknown> {
|
|
2842
3036
|
return typeof props === 'object' && props !== null ? (props as Record<string, unknown>) : {};
|
|
@@ -2886,6 +3080,9 @@ function createShell(kind: ElementKind): HTMLElement {
|
|
|
2886
3080
|
case 'host':
|
|
2887
3081
|
// The page's content element is adopted by the renderer (`adoptHostContent`).
|
|
2888
3082
|
return div('sb-host');
|
|
3083
|
+
case 'registered':
|
|
3084
|
+
// Its view is built by the renderer (`adoptRegistered`).
|
|
3085
|
+
return div('sb-registered');
|
|
2889
3086
|
case 'button': {
|
|
2890
3087
|
const el = document.createElement('button');
|
|
2891
3088
|
el.type = 'button';
|
|
@@ -2973,6 +3170,25 @@ function createShell(kind: ElementKind): HTMLElement {
|
|
|
2973
3170
|
SLOTS.set(el, slot);
|
|
2974
3171
|
return el;
|
|
2975
3172
|
}
|
|
3173
|
+
case 'disclosuregroup': {
|
|
3174
|
+
// The label (the first child) and the content below it, laid out as
|
|
3175
|
+
// a stack; a button over the label and a chevron at its trailing
|
|
3176
|
+
// edge, placed by layoutDom from the label's frame.
|
|
3177
|
+
const el = document.createElement('div');
|
|
3178
|
+
const slot = div('sb-slot');
|
|
3179
|
+
const toggle = document.createElement('button');
|
|
3180
|
+
toggle.type = 'button';
|
|
3181
|
+
toggle.className = 'sb-disclosure-toggle';
|
|
3182
|
+
toggle.setAttribute('aria-expanded', 'false');
|
|
3183
|
+
el.append(slot, toggle);
|
|
3184
|
+
const chevron = renderSymbol('chevron.right');
|
|
3185
|
+
if (chevron) {
|
|
3186
|
+
chevron.classList.add('sb-disclosure-chevron');
|
|
3187
|
+
el.append(chevron);
|
|
3188
|
+
}
|
|
3189
|
+
SLOTS.set(el, slot);
|
|
3190
|
+
return el;
|
|
3191
|
+
}
|
|
2976
3192
|
case 'toggle': {
|
|
2977
3193
|
const el = document.createElement('div');
|
|
2978
3194
|
const label = div('sb-slot sb-toggle-label');
|
|
@@ -3828,7 +4044,7 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
|
|
|
3828
4044
|
applyRefreshable(el, list.refreshable === true, list.refreshing === true);
|
|
3829
4045
|
// `.scrollContentBackground(.hidden)`: no grouped background (styles.css).
|
|
3830
4046
|
if (list.background === 'hidden') el.dataset['sbBackground'] = 'hidden';
|
|
3831
|
-
if (list.dismissesKeyboard
|
|
4047
|
+
if (dismissesKeyboard(list.dismissesKeyboard)) el.dataset['sbDismissesKeyboard'] = 'true';
|
|
3832
4048
|
applyEdgeEffect(el, list.edgeEffect);
|
|
3833
4049
|
break;
|
|
3834
4050
|
}
|
|
@@ -3859,7 +4075,7 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
|
|
|
3859
4075
|
applyRefreshable(el, scroll.refreshable === true, scroll.refreshing === true);
|
|
3860
4076
|
if (scroll.snap === 'viewAligned' || scroll.snap === 'paging') el.dataset['sbSnap'] = scroll.snap;
|
|
3861
4077
|
else delete el.dataset['sbSnap'];
|
|
3862
|
-
if (scroll.dismissesKeyboard
|
|
4078
|
+
if (dismissesKeyboard(scroll.dismissesKeyboard)) el.dataset['sbDismissesKeyboard'] = 'true';
|
|
3863
4079
|
if (scroll.reportsPosition === true) el.dataset['sbReportsPosition'] = '';
|
|
3864
4080
|
else delete el.dataset['sbReportsPosition'];
|
|
3865
4081
|
applyEdgeEffect(el, scroll.edgeEffect);
|
|
@@ -3869,6 +4085,12 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
|
|
|
3869
4085
|
case 'section':
|
|
3870
4086
|
applySection(el, props as SectionProps);
|
|
3871
4087
|
break;
|
|
4088
|
+
case 'disclosuregroup': {
|
|
4089
|
+
const expanded = (props as DisclosureGroupProps).expanded === true;
|
|
4090
|
+
el.toggleAttribute('data-sb-expanded', expanded);
|
|
4091
|
+
el.querySelector<HTMLElement>(':scope > .sb-disclosure-toggle')?.setAttribute('aria-expanded', String(expanded));
|
|
4092
|
+
break;
|
|
4093
|
+
}
|
|
3872
4094
|
case 'textfield':
|
|
3873
4095
|
applyTextField(el as HTMLInputElement | HTMLTextAreaElement, props as TextFieldProps);
|
|
3874
4096
|
break;
|
|
@@ -4018,6 +4240,9 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
|
|
|
4018
4240
|
case 'lazyspacer':
|
|
4019
4241
|
el.dataset['axis'] = (props as LazySpacerProps).axis === 'horizontal' ? 'horizontal' : 'vertical';
|
|
4020
4242
|
break;
|
|
4243
|
+
case 'registered':
|
|
4244
|
+
// The renderer builds its view (`adoptRegistered`).
|
|
4245
|
+
break;
|
|
4021
4246
|
default: {
|
|
4022
4247
|
const unknown: never = kind;
|
|
4023
4248
|
throw new RendererError(`Unknown element kind ${String(unknown)}`);
|
|
@@ -4164,9 +4389,10 @@ function applyShape(el: HTMLElement, props: ShapeProps): void {
|
|
|
4164
4389
|
s.border = `${width}px ${stroke.dash?.length ? 'dashed' : 'solid'} ${color}`;
|
|
4165
4390
|
el.dataset['sbStroke'] = stroke.inset ? 'inset' : 'center';
|
|
4166
4391
|
}
|
|
4167
|
-
// A fill with a drop shadow (`.shadow(.drop(…))` on the style)
|
|
4392
|
+
// A fill with a drop shadow (`.shadow(.drop(…))` on the style), then the shadows of the boxes around the shape
|
|
4393
|
+
// (castShadows), which styles.css gives a shape without one of its own.
|
|
4168
4394
|
const shadow = (props.fill as { shadow?: ShapeShadow } | null)?.shadow;
|
|
4169
|
-
s.filter = shadow && typeof shadow === 'object' ? shapeShadowCSS(shadow) : '';
|
|
4395
|
+
s.filter = shadow && typeof shadow === 'object' ? `${shapeShadowCSS(shadow)} var(--sb-shadow-filter, )` : '';
|
|
4170
4396
|
const mask = shapeTrimMask(props.trim);
|
|
4171
4397
|
if (mask) {
|
|
4172
4398
|
s.setProperty('mask-image', mask);
|
|
@@ -4409,6 +4635,10 @@ function applySection(el: HTMLElement, props: SectionProps): void {
|
|
|
4409
4635
|
header.textContent = props.header ?? '';
|
|
4410
4636
|
header.hidden = !props.header;
|
|
4411
4637
|
}
|
|
4638
|
+
// `Section(isExpanded:)`: a chevron on the header (a string one, or the
|
|
4639
|
+
// `sectionheader` view) in a sidebar-style list (styles.css); its tap toggles.
|
|
4640
|
+
el.toggleAttribute('data-sb-collapsible', props.collapsible === true);
|
|
4641
|
+
el.toggleAttribute('data-sb-expanded', props.collapsible === true && props.expanded !== false);
|
|
4412
4642
|
if (footer) {
|
|
4413
4643
|
footer.textContent = props.footer ?? '';
|
|
4414
4644
|
footer.hidden = !props.footer;
|
|
@@ -4771,15 +5001,29 @@ export function applyStyle(el: HTMLElement, style: Style): void {
|
|
|
4771
5001
|
}
|
|
4772
5002
|
|
|
4773
5003
|
// Phase 6 visual effects: CSS only, the layout engine never sees them.
|
|
4774
|
-
|
|
5004
|
+
// A shadow is not the box's own filter: as SwiftUI's `.shadow` on a container, it falls on each view the box draws
|
|
5005
|
+
// (castShadows). A box that clips, masks or composites what it holds draws it as one, so the shadows around it are
|
|
5006
|
+
// cast by it too (`--sb-shadow-filter`, which castShadows sets).
|
|
5007
|
+
const effects = effectsCSS(style, { shadow: false });
|
|
4775
5008
|
if (effects.transform) s.transform = effects.transform;
|
|
4776
5009
|
if (effects.transformOrigin) s.transformOrigin = effects.transformOrigin;
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
4781
|
-
|
|
4782
|
-
|
|
5010
|
+
const multiply = style.colorMultiply ? colorMultiplyFilter(style.colorMultiply, el) : '';
|
|
5011
|
+
const asOne = style.compositingGroup === true || style.cornerRadius != null || style.clipped === true || (style.mask != null && typeof style.mask === 'object');
|
|
5012
|
+
const filter = [multiply, effects.filter ?? '', asOne ? 'var(--sb-shadow-filter, )' : ''].filter(Boolean).join(' ');
|
|
5013
|
+
if (filter) s.filter = filter;
|
|
5014
|
+
if (asOne) el.dataset['sbAsOne'] = '';
|
|
5015
|
+
else delete el.dataset['sbAsOne'];
|
|
5016
|
+
if (style.shadow) {
|
|
5017
|
+
el.dataset['sbShadow'] = `drop-shadow(${shadowCSS(style.shadow)})`;
|
|
5018
|
+
el.dataset['sbShadowBox'] = shadowCSS(style.shadow);
|
|
5019
|
+
} else if (el.dataset['sbShadow'] !== undefined) {
|
|
5020
|
+
delete el.dataset['sbShadow'];
|
|
5021
|
+
delete el.dataset['sbShadowBox'];
|
|
5022
|
+
s.removeProperty('--sb-shadow-filter');
|
|
5023
|
+
s.removeProperty('--sb-shadow-box');
|
|
5024
|
+
}
|
|
5025
|
+
if (style.compositingGroup) el.dataset['sbComposite'] = '';
|
|
5026
|
+
else delete el.dataset['sbComposite'];
|
|
4783
5027
|
// Phase 7: `.hidden()` keeps the box (the engine lays it out) but draws
|
|
4784
5028
|
// nothing and takes no input; `.mask { }` clips the box to a style's alpha.
|
|
4785
5029
|
if (effects.hidden) {
|
|
@@ -4864,6 +5108,8 @@ export function applyStyle(el: HTMLElement, style: Style): void {
|
|
|
4864
5108
|
if (style.animationToken != null) el.dataset[ANIMATION_TOKEN] = String(style.animationToken);
|
|
4865
5109
|
// A `Label`'s icon: tinted in list rows (styles.css).
|
|
4866
5110
|
if (style.labelIcon) el.dataset['sbLabelIcon'] = '';
|
|
5111
|
+
// `.textSelection(.enabled)`: the text below can be selected (styles.css).
|
|
5112
|
+
el.toggleAttribute('data-sb-text-selection', style.textSelection === true);
|
|
4867
5113
|
if (style.animation) ANIMATIONS.set(el, style.animation);
|
|
4868
5114
|
if (style.transition) setTransition(el, style.transition);
|
|
4869
5115
|
|
package/src/scrollTransition.ts
CHANGED
|
@@ -35,7 +35,8 @@ export function setScrollTransition(el: HTMLElement, style: Style): void {
|
|
|
35
35
|
}
|
|
36
36
|
return;
|
|
37
37
|
}
|
|
38
|
-
|
|
38
|
+
// Its shadow is cast by the views it draws (renderer.ts castShadows), not by it.
|
|
39
|
+
const own = effectsCSS(style, { shadow: false });
|
|
39
40
|
registered.set(el, {
|
|
40
41
|
spec,
|
|
41
42
|
base: { transform: own.transform ?? '', filter: own.filter ?? '', opacity: style.opacity ?? 1 },
|