@swiftbrowser/web 0.5.1 → 0.5.3
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 +1 -1
- package/src/layout/engine.ts +59 -10
- package/src/layoutDom.ts +3 -2
- package/src/main.ts +6 -3
- package/src/renderer.ts +47 -7
- package/src/styles.css +129 -17
- package/src/symbols.ts +5 -3
package/README.md
CHANGED
|
@@ -308,7 +308,9 @@ scripts/build-wasm.sh Todos # the Todos example only
|
|
|
308
308
|
`sessionStorage['sb-state:<App>']`. On boot the snapshot is removed from
|
|
309
309
|
storage and passed to the new module as the WASI environment variable
|
|
310
310
|
`SB_STATE=<json>`, e.g. `SB_STATE={"ContentView#0":3}`. Modules without the
|
|
311
|
-
snapshot exports (Phase 1) are reloaded without state.
|
|
311
|
+
snapshot exports (Phase 1) are reloaded without state. This is the dev
|
|
312
|
+
server's only (`import.meta.hot`): a production build never stashes or
|
|
313
|
+
restores state, so a reload of a built site starts fresh.
|
|
312
314
|
|
|
313
315
|
Mock mode never stashes state. `window.__sb.snapshotState()` returns the
|
|
314
316
|
current snapshot for inspection.
|
package/package.json
CHANGED
package/src/layout/engine.ts
CHANGED
|
@@ -173,6 +173,11 @@ const SHEET_MEDIUM_HEIGHT = 433;
|
|
|
173
173
|
// iOS 26: without a grabber a sheet's content (and a navigation bar's row)
|
|
174
174
|
// starts 16 below its top.
|
|
175
175
|
const SHEET_CONTENT_TOP = 16;
|
|
176
|
+
// iPadOS: in a regular width a sheet is a form sheet, a card centered on the
|
|
177
|
+
// screen at UIKit's form-sheet size (clamped to the screen less a margin),
|
|
178
|
+
// whatever its detents; a full-screen cover stays full screen.
|
|
179
|
+
const FORM_SHEET: Size = { width: 540, height: 620 };
|
|
180
|
+
const FORM_SHEET_MARGIN = 20;
|
|
176
181
|
const LIST_INSET = 16;
|
|
177
182
|
const LIST_CELL_PADDING = 16;
|
|
178
183
|
// iOS 26 (measured in the iOS 26.2 simulator, Examples/Fidelity, in thirds
|
|
@@ -200,7 +205,7 @@ const LIST_SECTION_GAP = 107 / 3;
|
|
|
200
205
|
const LIST_SECTION_GAP_AFTER_FOOTER = 74 / 3;
|
|
201
206
|
const LIST_CHEVRON_WIDTH = 20;
|
|
202
207
|
// Phase 4.
|
|
203
|
-
// iOS 26: the tab bar is a 62-tall glass capsule floating
|
|
208
|
+
// iOS 26: the tab bar is a 62-tall glass capsule floating 21 above the bottom
|
|
204
209
|
// edge (8 when the tab view ends above the screen bottom), its items in equal
|
|
205
210
|
// slots inside a 4 inset. It is as wide as its tabs (268/3 each, measured in
|
|
206
211
|
// the 26.2 simulator: two tabs make a 186.67 capsule), centered, and at most
|
|
@@ -208,7 +213,7 @@ const LIST_CHEVRON_WIDTH = 20;
|
|
|
208
213
|
const TAB_BAR_HEIGHT = 62;
|
|
209
214
|
const TAB_BAR_ITEM_WIDTH = 268 / 3;
|
|
210
215
|
const TAB_BAR_MARGIN_H = 21;
|
|
211
|
-
const TAB_BAR_MARGIN_BOTTOM =
|
|
216
|
+
const TAB_BAR_MARGIN_BOTTOM = 21;
|
|
212
217
|
const TAB_BAR_MARGIN_BOTTOM_INSET = 8;
|
|
213
218
|
const TAB_BAR_ITEM_INSET = 4;
|
|
214
219
|
const PICKER_SEGMENTED_HEIGHT = 32;
|
|
@@ -241,11 +246,24 @@ const NAV_ITEM_SPACING = 16;
|
|
|
241
246
|
const NAV_ITEM_MARGIN = 16;
|
|
242
247
|
// iOS 26: the back button is a 44pt glass circle 16 from the leading edge,
|
|
243
248
|
// and the bar's leading and trailing items sit in glass capsules 44 tall,
|
|
244
|
-
// each item in a slot
|
|
245
|
-
// side
|
|
249
|
+
// each item in a slot: a symbol the 44 circle, anything else (text, a
|
|
250
|
+
// label) its width and 12 either side, at least 44; the groups 8 apart.
|
|
246
251
|
const NAV_GLASS_SIZE = 44;
|
|
247
252
|
const NAV_GLASS_PAD_H = 12;
|
|
248
253
|
const NAV_GROUP_SPACING = 8;
|
|
254
|
+
|
|
255
|
+
/** A bar item that is only a symbol (an `image`, or a button, menu or link around one). */
|
|
256
|
+
function isSymbolItem(item: LayoutNode): boolean {
|
|
257
|
+
const inner = unwrapStyled(item);
|
|
258
|
+
if (inner.kind === 'image') return true;
|
|
259
|
+
return inner.children.length === 1 && isSymbolItem(inner.children[0] as LayoutNode);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** A bar item's slot in its glass capsule. */
|
|
263
|
+
function navSlot(item: LayoutNode, width: number): number {
|
|
264
|
+
if (isSymbolItem(item) && width <= NAV_GLASS_SIZE) return NAV_GLASS_SIZE;
|
|
265
|
+
return Math.max(NAV_GLASS_SIZE, width + 2 * NAV_GLASS_PAD_H);
|
|
266
|
+
}
|
|
249
267
|
// iOS 26 (measured in the 26.2 simulator, Examples/Fidelity `bottombar*`): a
|
|
250
268
|
// bottom toolbar's items float on Liquid Glass in a 48 row 28 above the
|
|
251
269
|
// screen bottom (as the search field does) and 28 in from the sides.
|
|
@@ -1152,6 +1170,9 @@ export class LayoutPass {
|
|
|
1152
1170
|
// an `inspector` its trailing one to its panel).
|
|
1153
1171
|
// Phase 8: an `alert` is a full-screen layer too (scrim + card).
|
|
1154
1172
|
this.place(root, ctx, { width: screen.width, height: screen.height }, 0, 0);
|
|
1173
|
+
} else if (root.kind === 'sheet' && this.sheetIsForm(root)) {
|
|
1174
|
+
const rect = this.formSheetRect();
|
|
1175
|
+
this.place(root, ctx, { width: rect.width, height: rect.height }, rect.x, rect.y);
|
|
1155
1176
|
} else if (root.kind === 'sheet') {
|
|
1156
1177
|
const floats = this.sheetFloats(root);
|
|
1157
1178
|
const insetH = floats ? SHEET_FLOAT_INSET_H : 0;
|
|
@@ -1524,6 +1545,7 @@ export class LayoutPass {
|
|
|
1524
1545
|
// GeometryReader: the proposal, 10 on unconstrained axes.
|
|
1525
1546
|
return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
|
|
1526
1547
|
case 'sheet':
|
|
1548
|
+
if (this.sheetIsForm(node)) return { width: p.width ?? this.formSheetRect().width, height: this.formSheetRect().height };
|
|
1527
1549
|
return { width: p.width ?? this.env.screen.width, height: this.sheetHeight(node) };
|
|
1528
1550
|
// Phase 8 controls.
|
|
1529
1551
|
case 'alert':
|
|
@@ -1771,8 +1793,24 @@ export class LayoutPass {
|
|
|
1771
1793
|
return Array.isArray(detents) && str(detents[0]) === 'full';
|
|
1772
1794
|
}
|
|
1773
1795
|
|
|
1796
|
+
/** iPadOS: a sheet in a regular width (not a full-screen cover) is a centered form sheet. */
|
|
1797
|
+
private sheetIsForm(node: LayoutNode): boolean {
|
|
1798
|
+
return this.env.screen.width >= REGULAR_WIDTH && !this.sheetIsFull(node);
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
/** The form sheet's card: UIKit's size, clamped to the safe area less a margin, centered in it. */
|
|
1802
|
+
private formSheetRect(): Rect {
|
|
1803
|
+
const { screen, safeArea } = this.env;
|
|
1804
|
+
const top = safeArea.top + FORM_SHEET_MARGIN;
|
|
1805
|
+
const room = { width: screen.width - 2 * FORM_SHEET_MARGIN, height: screen.height - top - safeArea.bottom - FORM_SHEET_MARGIN };
|
|
1806
|
+
const width = Math.max(0, Math.min(FORM_SHEET.width, room.width));
|
|
1807
|
+
const height = Math.max(0, Math.min(FORM_SHEET.height, room.height));
|
|
1808
|
+
return { x: Math.round((screen.width - width) / 2), y: Math.round(top + (room.height - height) / 2), width, height };
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1774
1811
|
/** iOS 26: a sheet whose first detent is a partial one floats inset from the screen edges. */
|
|
1775
1812
|
private sheetFloats(node: LayoutNode): boolean {
|
|
1813
|
+
if (this.sheetIsForm(node)) return false;
|
|
1776
1814
|
const detents = node.props['detents'];
|
|
1777
1815
|
const first = Array.isArray(detents) ? str(detents[0]) : null;
|
|
1778
1816
|
return first === 'medium' || first?.startsWith('height:') === true || first?.startsWith('fraction:') === true;
|
|
@@ -1780,6 +1818,7 @@ export class LayoutPass {
|
|
|
1780
1818
|
|
|
1781
1819
|
/** iOS 26 shows the grabber only for a sheet with more than one detent. */
|
|
1782
1820
|
private sheetHasGrabber(node: LayoutNode): boolean {
|
|
1821
|
+
if (this.sheetIsForm(node)) return false;
|
|
1783
1822
|
const detents = node.props['detents'];
|
|
1784
1823
|
return Array.isArray(detents) && detents.length > 1 && !this.sheetIsFull(node);
|
|
1785
1824
|
}
|
|
@@ -3097,7 +3136,7 @@ export class LayoutPass {
|
|
|
3097
3136
|
const sizes = toolbar.children.map((item) => this.measure(item, itemCtx, { width: null, height: null }));
|
|
3098
3137
|
const placement = str(toolbar.props['placement']) ?? 'trailing';
|
|
3099
3138
|
if (placement === 'leading' || placement === 'trailing') {
|
|
3100
|
-
const slots = sizes.map((s) =>
|
|
3139
|
+
const slots = sizes.map((s, i) => navSlot(toolbar.children[i] as LayoutNode, s.width));
|
|
3101
3140
|
return { toolbar, placement, sizes, slots, total: slots.reduce((sum, w) => sum + w, 0) };
|
|
3102
3141
|
}
|
|
3103
3142
|
const total = sizes.reduce((sum, s) => sum + s.width, 0) + Math.max(0, sizes.length - 1) * NAV_ITEM_SPACING;
|
|
@@ -3125,7 +3164,7 @@ export class LayoutPass {
|
|
|
3125
3164
|
const itemCtx = this.childContext(toolbar, cctx);
|
|
3126
3165
|
return toolbar.children.map((item) => {
|
|
3127
3166
|
const itemSize = this.measure(item, itemCtx, { width: null, height: null });
|
|
3128
|
-
return { toolbar, item, size: itemSize, slot:
|
|
3167
|
+
return { toolbar, item, size: itemSize, slot: navSlot(item, itemSize.width) };
|
|
3129
3168
|
});
|
|
3130
3169
|
});
|
|
3131
3170
|
// Items of one toolbar share its glass capsule; separate toolbars sit 8 apart, as the trailing groups do.
|
|
@@ -3262,10 +3301,19 @@ export class LayoutPass {
|
|
|
3262
3301
|
);
|
|
3263
3302
|
const trailingUsed = trailingWidth > 0 || chrome['more'] !== undefined || inlineSecondary.length > 0;
|
|
3264
3303
|
const rightUsed = trailingUsed ? x + size.width - (trailingEdge + NAV_GROUP_SPACING) : 0;
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3304
|
+
// As UIKit places it: centered in the bar when it fits there; else slid
|
|
3305
|
+
// off-center into the room the items leave (between them, 8 clear of
|
|
3306
|
+
// each side), and truncated only when it does not fit even there.
|
|
3307
|
+
const roomStart = x + leftUsed + 8;
|
|
3308
|
+
const roomEnd = x + size.width - rightUsed - 8;
|
|
3309
|
+
const titleWidth = Math.min(
|
|
3310
|
+
Math.ceil(this.measurer.measureWidth(title, titleFont) - 0.001),
|
|
3311
|
+
Math.floor(size.width * 0.56),
|
|
3312
|
+
Math.floor(Math.max(0, roomEnd - roomStart)),
|
|
3313
|
+
);
|
|
3314
|
+
const centered = x + (size.width - titleWidth) / 2;
|
|
3315
|
+
const titleX = Math.max(roomStart, Math.min(centered, roomEnd - titleWidth));
|
|
3316
|
+
chrome['title'] = { x: titleX, y: rowY, width: titleWidth, height: NAV_BAR_HEIGHT };
|
|
3269
3317
|
}
|
|
3270
3318
|
this.setChrome(node, chrome);
|
|
3271
3319
|
|
|
@@ -4047,6 +4095,7 @@ export class LayoutPass {
|
|
|
4047
4095
|
};
|
|
4048
4096
|
const chrome: Record<string, Rect> = { content };
|
|
4049
4097
|
if (floats) chrome['floating'] = { x, y, width: size.width, height: size.height };
|
|
4098
|
+
if (this.sheetIsForm(node)) chrome['form'] = { x, y, width: size.width, height: size.height };
|
|
4050
4099
|
if (grabber) {
|
|
4051
4100
|
chrome['grabber'] = {
|
|
4052
4101
|
x: x + (size.width - SHEET_GRABBER.width) / 2,
|
package/src/layoutDom.ts
CHANGED
|
@@ -616,6 +616,8 @@ function applyChrome(
|
|
|
616
616
|
placeChrome(grabber, chrome?.['grabber'], frame);
|
|
617
617
|
// iOS 26: a sheet at a partial detent floats as a rounded card (styles.css).
|
|
618
618
|
setFlag(el, 'sbFloating', chrome?.['floating'] ? '' : null);
|
|
619
|
+
// iPadOS: in a regular width, a centered form sheet (styles.css).
|
|
620
|
+
setFlag(el, 'sbForm', chrome?.['form'] ? '' : null);
|
|
619
621
|
return;
|
|
620
622
|
}
|
|
621
623
|
case 'tabview': {
|
|
@@ -633,8 +635,7 @@ function applyChrome(
|
|
|
633
635
|
return;
|
|
634
636
|
}
|
|
635
637
|
case 'navsplit': {
|
|
636
|
-
// Phase 7: the
|
|
637
|
-
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-navsplit-separator'), chrome?.['separator'], box);
|
|
638
|
+
// Phase 7: the sidebar toggle (the sidebar column draws the hairline).
|
|
638
639
|
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-navsplit-toggle'), chrome?.['toggle'], box);
|
|
639
640
|
setFlag(el, 'sbSidebarHidden', node.props['sidebarVisible'] === false ? '' : null);
|
|
640
641
|
return;
|
package/src/main.ts
CHANGED
|
@@ -479,6 +479,9 @@ async function loadAssetCatalog(app: string): Promise<AssetCatalog> {
|
|
|
479
479
|
// ---------------------------------------------------------------------------
|
|
480
480
|
// Hot reload: state snapshot across full reloads
|
|
481
481
|
// ---------------------------------------------------------------------------
|
|
482
|
+
//
|
|
483
|
+
// Only the dev server's page (`import.meta.hot`) keeps state across a reload:
|
|
484
|
+
// a built site starts fresh, as an app does at launch.
|
|
482
485
|
|
|
483
486
|
const stateKey = `sb-state:${requestedApp}`;
|
|
484
487
|
|
|
@@ -496,6 +499,7 @@ function stashState(): void {
|
|
|
496
499
|
|
|
497
500
|
/** Read and delete a stashed snapshot (it is only ever used once). */
|
|
498
501
|
function takeStashedState(): string | null {
|
|
502
|
+
if (!import.meta.hot) return null;
|
|
499
503
|
try {
|
|
500
504
|
const snapshot = sessionStorage.getItem(stateKey);
|
|
501
505
|
if (snapshot !== null) sessionStorage.removeItem(stateKey);
|
|
@@ -505,10 +509,9 @@ function takeStashedState(): string | null {
|
|
|
505
509
|
}
|
|
506
510
|
}
|
|
507
511
|
|
|
508
|
-
window.addEventListener('beforeunload', stashState);
|
|
509
|
-
window.addEventListener('pagehide', stashState);
|
|
510
|
-
|
|
511
512
|
if (import.meta.hot) {
|
|
513
|
+
window.addEventListener('beforeunload', stashState);
|
|
514
|
+
window.addEventListener('pagehide', stashState);
|
|
512
515
|
import.meta.hot.on('vite:beforeFullReload', () => stashState());
|
|
513
516
|
import.meta.hot.on('sb:build-start', (payload: BuildStartPayload) => {
|
|
514
517
|
buildStatus.textContent = `Rebuilding ${payload.apps.join(', ')}…`;
|
package/src/renderer.ts
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* control claimed.
|
|
28
28
|
*
|
|
29
29
|
* Phase 7: `navsplit` holds two `navstack` columns with a hairline between
|
|
30
|
-
* them (
|
|
30
|
+
* them (drawn by the sidebar column in the engine's 1px `separator` gap);
|
|
31
31
|
* a `navlink` with `selected: true` carries `data-sb-selected` (a rounded fill
|
|
32
32
|
* background; the engine drops its chevron inside a split view's sidebar);
|
|
33
33
|
* the candidates a `viewthatfits` did not choose get the `hidden` attribute.
|
|
@@ -187,6 +187,25 @@ function animationTokenOf(kind: ElementKind | undefined, props: unknown): string
|
|
|
187
187
|
return token == null ? undefined : String(token);
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
/** Duration of the menu's fade out; keep in sync with styles.css (`sb-menu-out`). */
|
|
191
|
+
export const MENU_EXIT_MS = 150;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Put a closed popover away: it stops being the menu (no `sb-picker-menu`
|
|
195
|
+
* class or `menu` role, inert) and fades out where it was, then leaves the
|
|
196
|
+
* DOM, as the open animation brought it in.
|
|
197
|
+
*/
|
|
198
|
+
function dismissMenu(menu: HTMLElement): void {
|
|
199
|
+
menu.classList.replace('sb-picker-menu', 'sb-picker-menu-exit');
|
|
200
|
+
menu.removeAttribute('role');
|
|
201
|
+
menu.setAttribute('aria-hidden', 'true');
|
|
202
|
+
menu.inert = true;
|
|
203
|
+
const remove = (): void => menu.remove();
|
|
204
|
+
menu.addEventListener('animationend', remove, { once: true });
|
|
205
|
+
// Without animations (reduced motion, a hidden page, jsdom) it still goes.
|
|
206
|
+
setTimeout(remove, MENU_EXIT_MS + 100);
|
|
207
|
+
}
|
|
208
|
+
|
|
190
209
|
/** Duration of the navigation push/pop slide; keep in sync with styles.css (`--sb-nav-easing`). */
|
|
191
210
|
export const NAV_TRANSITION_MS = 400;
|
|
192
211
|
/** Duration of the sheet present/dismiss slide; keep in sync with styles.css. */
|
|
@@ -1373,7 +1392,7 @@ export class Renderer {
|
|
|
1373
1392
|
if (!menu) return;
|
|
1374
1393
|
this.menu = null;
|
|
1375
1394
|
document.removeEventListener('click', this.onDocumentClick, true);
|
|
1376
|
-
menu.el
|
|
1395
|
+
dismissMenu(menu.el);
|
|
1377
1396
|
menu.anchor.setAttribute('aria-expanded', 'false');
|
|
1378
1397
|
// Lazy menus: Swift drops the items. After the current task, so that the
|
|
1379
1398
|
// tap of the item chosen (sent right after closing) reaches its button.
|
|
@@ -2209,12 +2228,11 @@ function createShell(kind: ElementKind): HTMLElement {
|
|
|
2209
2228
|
return el;
|
|
2210
2229
|
}
|
|
2211
2230
|
case 'navsplit': {
|
|
2212
|
-
// Phase 7: the two columns go into the slot; the
|
|
2213
|
-
//
|
|
2231
|
+
// Phase 7: the two columns go into the slot; the sidebar column draws
|
|
2232
|
+
// the hairline in the gap the engine leaves (styles.css), so it slides
|
|
2233
|
+
// with the column.
|
|
2214
2234
|
const el = document.createElement('div');
|
|
2215
2235
|
const slot = div('sb-slot sb-navsplit-columns');
|
|
2216
|
-
const separator = div('sb-navsplit-separator');
|
|
2217
|
-
separator.setAttribute('aria-hidden', 'true');
|
|
2218
2236
|
// iPadOS: the sidebar toggle, at the engine's `toggle` rect; a tap
|
|
2219
2237
|
// asks Swift to show or hide the sidebar.
|
|
2220
2238
|
const toggle = document.createElement('button');
|
|
@@ -2223,7 +2241,7 @@ function createShell(kind: ElementKind): HTMLElement {
|
|
|
2223
2241
|
toggle.setAttribute('aria-label', 'Toggle Sidebar');
|
|
2224
2242
|
const symbol = renderSymbol('sidebar.left');
|
|
2225
2243
|
if (symbol) toggle.append(symbol);
|
|
2226
|
-
el.append(slot,
|
|
2244
|
+
el.append(slot, toggle);
|
|
2227
2245
|
SLOTS.set(el, slot);
|
|
2228
2246
|
return el;
|
|
2229
2247
|
}
|
|
@@ -2742,7 +2760,23 @@ function applyTextEditor(area: HTMLTextAreaElement, props: TextEditorProps): voi
|
|
|
2742
2760
|
// Props → DOM
|
|
2743
2761
|
// ---------------------------------------------------------------------------
|
|
2744
2762
|
|
|
2763
|
+
/** Elements whose `aria-label` is their `accessibilityLabel` prop. */
|
|
2764
|
+
const LABELLED = new WeakSet<HTMLElement>();
|
|
2765
|
+
|
|
2745
2766
|
function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, assets: AssetCatalog): void {
|
|
2767
|
+
// A label that went away leaves the element's own (an image's name) to the kind below.
|
|
2768
|
+
if (LABELLED.delete(el)) el.removeAttribute('aria-label');
|
|
2769
|
+
applyKindProps(el, kind, props, assets);
|
|
2770
|
+
// `.accessibilityLabel(_:)`, or a `Label`'s title (an icon-only label in
|
|
2771
|
+
// a button is named by it): the element's accessible name.
|
|
2772
|
+
const label = (props as { accessibilityLabel?: unknown }).accessibilityLabel;
|
|
2773
|
+
if (typeof label === 'string' && label !== '') {
|
|
2774
|
+
el.setAttribute('aria-label', label);
|
|
2775
|
+
LABELLED.add(el);
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps, assets: AssetCatalog): void {
|
|
2746
2780
|
switch (kind) {
|
|
2747
2781
|
case 'host':
|
|
2748
2782
|
el.dataset['sbSizing'] = (props as HostProps).sizing;
|
|
@@ -3103,8 +3137,14 @@ const SHAPE_RADIUS: Record<Exclude<ShapeProps['shape'], 'path'>, (cornerRadius:
|
|
|
3103
3137
|
* flat). The stroke is a CSS border, which lies inside the box: that is
|
|
3104
3138
|
* `.strokeBorder` (`inset: true`); a plain `.stroke` is drawn the same way,
|
|
3105
3139
|
* and `data-sb-stroke` records which one it was.
|
|
3140
|
+
*
|
|
3141
|
+
* A shape that is only stroked is marked `data-sb-stroke-only`: it takes no
|
|
3142
|
+
* hits (styles.css), so an outline overlaid on a control leaves its box to
|
|
3143
|
+
* what is under it, as SwiftUI hit-tests a stroke only along its line.
|
|
3106
3144
|
*/
|
|
3107
3145
|
function applyShape(el: HTMLElement, props: ShapeProps): void {
|
|
3146
|
+
if (props.stroke && !props.fill) el.dataset['sbStrokeOnly'] = '';
|
|
3147
|
+
else delete el.dataset['sbStrokeOnly'];
|
|
3108
3148
|
if (props.shape === 'path') {
|
|
3109
3149
|
// A custom shape: an SVG of its path (src/pathShape.ts).
|
|
3110
3150
|
el.dataset['sbShape'] = 'path';
|
package/src/styles.css
CHANGED
|
@@ -83,11 +83,28 @@
|
|
|
83
83
|
--sb-color-grabber: rgba(60, 60, 67, 0.3);
|
|
84
84
|
/* Phase 4 */
|
|
85
85
|
--sb-color-tabbar-background: rgba(249, 249, 249, 0.94);
|
|
86
|
-
/* iOS 26 Liquid Glass, approximated: a
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
86
|
+
/* iOS 26 Liquid Glass, approximated: a white fill at 70% over a blurred
|
|
87
|
+
backdrop (pure white over white content, 251 over the grouped
|
|
88
|
+
background, as on an iPhone) with a soft shadow and a 1pt rim that catches the light
|
|
89
|
+
at the top-left and bottom-right (bar buttons, the tab bar, the bottom
|
|
90
|
+
toolbar and search field; the "Liquid Glass rim" rule draws it). The rim
|
|
91
|
+
is white in both schemes: a faint bright edge on the near-white fill, the
|
|
92
|
+
gray hairline iOS draws on the dark one. Alphas measured on an iPhone
|
|
93
|
+
(3x): over the dark fill the rim is 16% white at its brightest, 12.5%
|
|
94
|
+
at the sides and 4.5% at the top-right and bottom-left. */
|
|
95
|
+
--sb-glass-background: rgba(255, 255, 255, 0.7);
|
|
96
|
+
--sb-glass-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
|
|
97
|
+
--sb-glass-rim: conic-gradient(
|
|
98
|
+
rgba(255, 255, 255, 0.6) 0deg,
|
|
99
|
+
rgba(255, 255, 255, 0.3) 45deg,
|
|
100
|
+
rgba(255, 255, 255, 0.6) 90deg,
|
|
101
|
+
rgba(255, 255, 255, 0.95) 135deg,
|
|
102
|
+
rgba(255, 255, 255, 0.6) 180deg,
|
|
103
|
+
rgba(255, 255, 255, 0.3) 225deg,
|
|
104
|
+
rgba(255, 255, 255, 0.6) 270deg,
|
|
105
|
+
rgba(255, 255, 255, 0.95) 315deg,
|
|
106
|
+
rgba(255, 255, 255, 0.6) 360deg
|
|
107
|
+
);
|
|
91
108
|
--sb-glass-selected: rgba(120, 120, 128, 0.16);
|
|
92
109
|
--sb-color-segment-track: rgba(118, 118, 128, 0.12);
|
|
93
110
|
--sb-color-segment-selected: #ffffff;
|
|
@@ -130,7 +147,18 @@
|
|
|
130
147
|
--sb-color-field-background: #1c1c1e;
|
|
131
148
|
--sb-color-placeholder: rgba(235, 235, 245, 0.3);
|
|
132
149
|
--sb-glass-background: rgba(30, 30, 30, 0.84);
|
|
133
|
-
--sb-glass-shadow: 0 4px 24px rgba(0, 0, 0, 0.4)
|
|
150
|
+
--sb-glass-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
|
151
|
+
--sb-glass-rim: conic-gradient(
|
|
152
|
+
rgba(255, 255, 255, 0.125) 0deg,
|
|
153
|
+
rgba(255, 255, 255, 0.045) 45deg,
|
|
154
|
+
rgba(255, 255, 255, 0.125) 90deg,
|
|
155
|
+
rgba(255, 255, 255, 0.16) 135deg,
|
|
156
|
+
rgba(255, 255, 255, 0.125) 180deg,
|
|
157
|
+
rgba(255, 255, 255, 0.045) 225deg,
|
|
158
|
+
rgba(255, 255, 255, 0.125) 270deg,
|
|
159
|
+
rgba(255, 255, 255, 0.16) 315deg,
|
|
160
|
+
rgba(255, 255, 255, 0.125) 360deg
|
|
161
|
+
);
|
|
134
162
|
--sb-glass-selected: rgba(120, 120, 128, 0.32);
|
|
135
163
|
/* Sheets float above the window and take the elevated system background. */
|
|
136
164
|
--sb-color-sheet-background: #1c1c1e;
|
|
@@ -1374,6 +1402,25 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
1374
1402
|
background: rgba(0, 0, 0, 0.22);
|
|
1375
1403
|
}
|
|
1376
1404
|
|
|
1405
|
+
/* iPadOS: in a regular width a sheet is a form sheet, centered and rounded
|
|
1406
|
+
all around. It slides up from below the screen, not just its own height,
|
|
1407
|
+
and the presenting tree stays as it is (only dimmed). */
|
|
1408
|
+
.sb-sheet[data-sb-form] > .sb-sheet-card {
|
|
1409
|
+
--sb-sheet-offscreen: 100vh;
|
|
1410
|
+
border-radius: 26px;
|
|
1411
|
+
box-shadow: 0 10px 50px rgba(0, 0, 0, 0.25);
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/* In dark mode what a form sheet holds takes iOS's elevated colors, as the
|
|
1415
|
+
card does: the base ones are black and would paint it black. */
|
|
1416
|
+
.device[data-theme="dark"] .sb-sheet[data-sb-form] > .sb-sheet-card {
|
|
1417
|
+
--sb-color-system-background: #1c1c1e;
|
|
1418
|
+
--sb-color-secondary-system-background: #2c2c2e;
|
|
1419
|
+
--sb-color-grouped-background: #1c1c1e;
|
|
1420
|
+
--sb-color-grouped-cell: #2c2c2e;
|
|
1421
|
+
--sb-color-field-background: #2c2c2e;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1377
1424
|
.sb-sheet-grabber {
|
|
1378
1425
|
position: absolute;
|
|
1379
1426
|
border-radius: 2.5px;
|
|
@@ -1422,13 +1469,13 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
1422
1469
|
transition: transform 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
|
|
1423
1470
|
}
|
|
1424
1471
|
|
|
1425
|
-
.screen[data-sb-sheet="large"] > :not(.sb-sheet, .sb-exit-clone, .sb-picker-menu) {
|
|
1472
|
+
.screen[data-sb-sheet="large"]:where(:not(:has(> .sb-sheet[data-sb-sheet-position="top"][data-sb-form]))) > :not(.sb-sheet, .sb-exit-clone, .sb-picker-menu, .sb-picker-menu-exit) {
|
|
1426
1473
|
transform: scale(0.92);
|
|
1427
1474
|
}
|
|
1428
1475
|
|
|
1429
1476
|
@keyframes sb-sheet-in {
|
|
1430
1477
|
from {
|
|
1431
|
-
transform: translateY(100%);
|
|
1478
|
+
transform: translateY(var(--sb-sheet-offscreen, 100%));
|
|
1432
1479
|
}
|
|
1433
1480
|
to {
|
|
1434
1481
|
transform: translateY(0);
|
|
@@ -1440,7 +1487,7 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
1440
1487
|
transform: translateY(var(--sb-sheet-drag, 0px));
|
|
1441
1488
|
}
|
|
1442
1489
|
to {
|
|
1443
|
-
transform: translateY(100%);
|
|
1490
|
+
transform: translateY(var(--sb-sheet-offscreen, 100%));
|
|
1444
1491
|
}
|
|
1445
1492
|
}
|
|
1446
1493
|
|
|
@@ -1777,8 +1824,10 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
1777
1824
|
stroke-width: 2.5;
|
|
1778
1825
|
}
|
|
1779
1826
|
|
|
1780
|
-
/* The option menu: anchored under (or above) the control by the renderer.
|
|
1781
|
-
|
|
1827
|
+
/* The option menu: anchored under (or above) the control by the renderer.
|
|
1828
|
+
Closed, it fades out where it was (`.sb-picker-menu-exit`) and leaves. */
|
|
1829
|
+
.sb-picker-menu,
|
|
1830
|
+
.sb-picker-menu-exit {
|
|
1782
1831
|
position: absolute;
|
|
1783
1832
|
z-index: 6;
|
|
1784
1833
|
border-radius: 13px;
|
|
@@ -1793,7 +1842,12 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
1793
1842
|
animation: sb-menu-in 0.18s cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
|
1794
1843
|
}
|
|
1795
1844
|
|
|
1796
|
-
.sb-picker-menu
|
|
1845
|
+
.sb-picker-menu-exit {
|
|
1846
|
+
pointer-events: none;
|
|
1847
|
+
animation: sb-menu-out 0.15s ease-in both;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
:is(.sb-picker-menu, .sb-picker-menu-exit)[data-sb-placement="above"] {
|
|
1797
1851
|
transform-origin: 50% 100%;
|
|
1798
1852
|
}
|
|
1799
1853
|
|
|
@@ -1862,6 +1916,17 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
1862
1916
|
}
|
|
1863
1917
|
}
|
|
1864
1918
|
|
|
1919
|
+
@keyframes sb-menu-out {
|
|
1920
|
+
from {
|
|
1921
|
+
opacity: 1;
|
|
1922
|
+
transform: scale(1);
|
|
1923
|
+
}
|
|
1924
|
+
to {
|
|
1925
|
+
opacity: 0;
|
|
1926
|
+
transform: scale(0.96);
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1865
1930
|
/* GeometryReader: a plain box the engine sizes to its proposal. */
|
|
1866
1931
|
.sb-geometry {
|
|
1867
1932
|
display: block;
|
|
@@ -1935,6 +2000,7 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
1935
2000
|
|
|
1936
2001
|
@media (prefers-reduced-motion: reduce) {
|
|
1937
2002
|
.sb-picker-menu,
|
|
2003
|
+
.sb-picker-menu-exit,
|
|
1938
2004
|
.sb-segment::before {
|
|
1939
2005
|
animation-duration: 0.01s;
|
|
1940
2006
|
transition-duration: 0.01s;
|
|
@@ -2139,6 +2205,40 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
2139
2205
|
display: none;
|
|
2140
2206
|
}
|
|
2141
2207
|
|
|
2208
|
+
/* Liquid Glass rim: a 1pt ring at the edge of every glass surface whose
|
|
2209
|
+
brightness varies around it (`--sb-glass-rim`, a conic gradient brightest
|
|
2210
|
+
at the top-left and bottom-right, where iOS's glass catches the light,
|
|
2211
|
+
dimmest at the top-right and bottom-left). A gradient-filled pseudo-element
|
|
2212
|
+
the size of the surface, masked to its outer 1px (the content box cut out
|
|
2213
|
+
of the padding box); it takes no input and sits above the surface's items,
|
|
2214
|
+
which never reach the edge. */
|
|
2215
|
+
.sb-navbar-back::after,
|
|
2216
|
+
.sb-navbar-more::after,
|
|
2217
|
+
.sb-tabbar::after,
|
|
2218
|
+
.sb-toolbar[data-sb-placement="leading"]::after,
|
|
2219
|
+
.sb-toolbar[data-sb-placement="trailing"]::after,
|
|
2220
|
+
.sb-bottom-glass::after,
|
|
2221
|
+
.sb-searchfield::after,
|
|
2222
|
+
.sb-navsplit-toggle::after {
|
|
2223
|
+
content: "";
|
|
2224
|
+
position: absolute;
|
|
2225
|
+
inset: 0;
|
|
2226
|
+
z-index: 1;
|
|
2227
|
+
box-sizing: border-box;
|
|
2228
|
+
padding: 1px;
|
|
2229
|
+
border-radius: inherit;
|
|
2230
|
+
background: var(--sb-glass-rim);
|
|
2231
|
+
-webkit-mask:
|
|
2232
|
+
linear-gradient(#000 0 0) content-box,
|
|
2233
|
+
linear-gradient(#000 0 0);
|
|
2234
|
+
-webkit-mask-composite: xor;
|
|
2235
|
+
mask:
|
|
2236
|
+
linear-gradient(#000 0 0) content-box,
|
|
2237
|
+
linear-gradient(#000 0 0);
|
|
2238
|
+
mask-composite: exclude;
|
|
2239
|
+
pointer-events: none;
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2142
2242
|
/* iOS 26: a bar's leading and trailing items share a glass capsule (the
|
|
2143
2243
|
engine sizes the group: 44 tall, each item in a slot at least 44 wide),
|
|
2144
2244
|
drawn in the label color unless tinted. */
|
|
@@ -2268,7 +2368,9 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
2268
2368
|
------------------------------------------------------------------------- */
|
|
2269
2369
|
|
|
2270
2370
|
/* NavigationSplitView: two navstack columns (protocol children, positioned by
|
|
2271
|
-
the engine) with a hairline between them
|
|
2371
|
+
the engine) with a hairline between them. The sidebar column draws it in
|
|
2372
|
+
the 1pt gap the engine leaves (its `separator` rect), so the hairline
|
|
2373
|
+
slides with the sidebar as the inspector's does with its panel. */
|
|
2272
2374
|
.sb-navsplit {
|
|
2273
2375
|
overflow: hidden;
|
|
2274
2376
|
background: var(--sb-color-system-background);
|
|
@@ -2286,10 +2388,8 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
2286
2388
|
box-shadow: -1px 0 0 var(--sb-color-separator);
|
|
2287
2389
|
}
|
|
2288
2390
|
|
|
2289
|
-
.sb-navsplit-
|
|
2290
|
-
|
|
2291
|
-
background: var(--sb-color-separator);
|
|
2292
|
-
pointer-events: none;
|
|
2391
|
+
.sb-navsplit-columns > [data-sb-id]:first-child {
|
|
2392
|
+
box-shadow: 1px 0 0 var(--sb-color-separator);
|
|
2293
2393
|
}
|
|
2294
2394
|
|
|
2295
2395
|
/* iPadOS: the sidebar toggle, a 44pt glass circle at the leading edge of
|
|
@@ -2515,6 +2615,18 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
2515
2615
|
pointer-events: auto;
|
|
2516
2616
|
}
|
|
2517
2617
|
|
|
2618
|
+
/* A shape that is only stroked (`.stroke`, `.strokeBorder`) is hit along its
|
|
2619
|
+
line in SwiftUI, not across its box: an outline overlaid on a control must
|
|
2620
|
+
not swallow its taps. Its box takes none, except as the content of a
|
|
2621
|
+
gesture (`Circle().stroke().onTapGesture`), where it stays the target. */
|
|
2622
|
+
[data-sb-kind].sb-shape[data-sb-stroke-only] {
|
|
2623
|
+
pointer-events: none;
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
:is(.sb-gesture, .sb-tapgesture) > [data-sb-kind].sb-shape[data-sb-stroke-only] {
|
|
2627
|
+
pointer-events: auto;
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2518
2630
|
:is([data-sb-hidden], [data-sb-disabled], [data-sb-hit-testing="false"], .sb-navscreen--pop, .sb-exit-clone, .sb-sheet--out, .sb-alert--out, .sb-tab[data-sb-tab-hidden]) [data-sb-kind],
|
|
2519
2631
|
:is([data-sb-hidden], [data-sb-disabled], [data-sb-hit-testing="false"], .sb-navscreen--pop, .sb-exit-clone, .sb-sheet--out, .sb-alert--out, .sb-tab[data-sb-tab-hidden]) .sb-navbar > * {
|
|
2520
2632
|
pointer-events: none !important;
|
package/src/symbols.ts
CHANGED
|
@@ -1103,13 +1103,13 @@ const GENERIC_SHAPES = new Set(['circle', 'square', 'rectangle', 'app', 'capsule
|
|
|
1103
1103
|
* is drawn as `person.crop.circle`, `trash.circle.fill` as `trash.fill` and
|
|
1104
1104
|
* `speaker.wave.2.fill` as `speaker.wave.2`. A `.badge…` suffix is dropped,
|
|
1105
1105
|
* then trailing parts one at a time; a name with `.fill` tries every filled
|
|
1106
|
-
* form first. A `.slash` variant
|
|
1106
|
+
* form first. A `.slash` variant keeps its slash (`speaker.slash.fill` is
|
|
1107
|
+
* drawn as `speaker.slash`) and is never drawn as its base, which would
|
|
1107
1108
|
* mean the opposite (`bell.slash` is "notifications off"); nor is a name
|
|
1108
1109
|
* drawn as a bare shape. Unresolved names get the caller's placeholder.
|
|
1109
1110
|
*/
|
|
1110
1111
|
function resolveVariant(systemName: string): SymbolGlyph | undefined {
|
|
1111
1112
|
let parts = systemName.split('.');
|
|
1112
|
-
if (parts.includes('slash')) return undefined;
|
|
1113
1113
|
const fill = parts.includes('fill');
|
|
1114
1114
|
parts = parts.filter((part) => part !== 'fill');
|
|
1115
1115
|
const badge = parts.indexOf('badge');
|
|
@@ -1130,7 +1130,9 @@ function resolveVariant(systemName: string): SymbolGlyph | undefined {
|
|
|
1130
1130
|
}
|
|
1131
1131
|
}
|
|
1132
1132
|
const bases: string[] = [];
|
|
1133
|
-
|
|
1133
|
+
// Never shorter than the slash.
|
|
1134
|
+
const shortest = Math.max(1, parts.indexOf('slash') + 1);
|
|
1135
|
+
for (let length = parts.length; length >= shortest; length--) {
|
|
1134
1136
|
const base = parts.slice(0, length).join('.');
|
|
1135
1137
|
if (length === 1 && parts.length > 1 && GENERIC_SHAPES.has(base)) break;
|
|
1136
1138
|
bases.push(base);
|