@swiftbrowser/web 0.5.2 → 0.5.4
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 +61 -8
- 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 +12 -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,20 +246,42 @@ 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
|
+
/** Whether any text is laid out in an item (a bottom bar pads text items as text). */
|
|
256
|
+
function hasText(item: LayoutNode): boolean {
|
|
257
|
+
return item.kind === 'text' || item.children.some(hasText);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** A bar item that is only a symbol (an `image`, or a button, menu or link around one). */
|
|
261
|
+
function isSymbolItem(item: LayoutNode): boolean {
|
|
262
|
+
const inner = unwrapStyled(item);
|
|
263
|
+
if (inner.kind === 'image') return true;
|
|
264
|
+
return inner.children.length === 1 && isSymbolItem(inner.children[0] as LayoutNode);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** A bar item's slot in its glass capsule. */
|
|
268
|
+
function navSlot(item: LayoutNode, width: number): number {
|
|
269
|
+
if (isSymbolItem(item) && width <= NAV_GLASS_SIZE) return NAV_GLASS_SIZE;
|
|
270
|
+
return Math.max(NAV_GLASS_SIZE, width + 2 * NAV_GLASS_PAD_H);
|
|
271
|
+
}
|
|
249
272
|
// iOS 26 (measured in the 26.2 simulator, Examples/Fidelity `bottombar*`): a
|
|
250
273
|
// bottom toolbar's items float on Liquid Glass in a 48 row 28 above the
|
|
251
274
|
// screen bottom (as the search field does) and 28 in from the sides.
|
|
252
275
|
// Consecutive items share a capsule: an icon takes a 50 slot, text its width
|
|
253
|
-
// and 18 either side, 6 between slots; a lone icon is a 48 circle.
|
|
276
|
+
// and 18 either side, 6 between slots; a lone icon is a 48 circle. An item
|
|
277
|
+
// with no text (symbols, a symbol and a chevron, a custom view) is an icon
|
|
278
|
+
// at any width: at least 50, else its width and 8 either side, so a little
|
|
279
|
+
// more width gives it a little more room (not text's 18 a side).
|
|
254
280
|
const BOTTOM_GLASS_HEIGHT = 48;
|
|
255
281
|
const BOTTOM_GLASS_MARGIN_H = 28;
|
|
256
282
|
const BOTTOM_ICON_SLOT = 50;
|
|
257
283
|
const BOTTOM_TEXT_PAD_H = 18;
|
|
284
|
+
const BOTTOM_ICON_PAD_H = 8;
|
|
258
285
|
const BOTTOM_ITEM_GAP = 6;
|
|
259
286
|
/** A fixed `ToolbarSpacer` between two bottom groups. */
|
|
260
287
|
const BOTTOM_FIXED_SPACER = 8;
|
|
@@ -1152,6 +1179,9 @@ export class LayoutPass {
|
|
|
1152
1179
|
// an `inspector` its trailing one to its panel).
|
|
1153
1180
|
// Phase 8: an `alert` is a full-screen layer too (scrim + card).
|
|
1154
1181
|
this.place(root, ctx, { width: screen.width, height: screen.height }, 0, 0);
|
|
1182
|
+
} else if (root.kind === 'sheet' && this.sheetIsForm(root)) {
|
|
1183
|
+
const rect = this.formSheetRect();
|
|
1184
|
+
this.place(root, ctx, { width: rect.width, height: rect.height }, rect.x, rect.y);
|
|
1155
1185
|
} else if (root.kind === 'sheet') {
|
|
1156
1186
|
const floats = this.sheetFloats(root);
|
|
1157
1187
|
const insetH = floats ? SHEET_FLOAT_INSET_H : 0;
|
|
@@ -1524,6 +1554,7 @@ export class LayoutPass {
|
|
|
1524
1554
|
// GeometryReader: the proposal, 10 on unconstrained axes.
|
|
1525
1555
|
return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
|
|
1526
1556
|
case 'sheet':
|
|
1557
|
+
if (this.sheetIsForm(node)) return { width: p.width ?? this.formSheetRect().width, height: this.formSheetRect().height };
|
|
1527
1558
|
return { width: p.width ?? this.env.screen.width, height: this.sheetHeight(node) };
|
|
1528
1559
|
// Phase 8 controls.
|
|
1529
1560
|
case 'alert':
|
|
@@ -1771,8 +1802,24 @@ export class LayoutPass {
|
|
|
1771
1802
|
return Array.isArray(detents) && str(detents[0]) === 'full';
|
|
1772
1803
|
}
|
|
1773
1804
|
|
|
1805
|
+
/** iPadOS: a sheet in a regular width (not a full-screen cover) is a centered form sheet. */
|
|
1806
|
+
private sheetIsForm(node: LayoutNode): boolean {
|
|
1807
|
+
return this.env.screen.width >= REGULAR_WIDTH && !this.sheetIsFull(node);
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
/** The form sheet's card: UIKit's size, clamped to the safe area less a margin, centered in it. */
|
|
1811
|
+
private formSheetRect(): Rect {
|
|
1812
|
+
const { screen, safeArea } = this.env;
|
|
1813
|
+
const top = safeArea.top + FORM_SHEET_MARGIN;
|
|
1814
|
+
const room = { width: screen.width - 2 * FORM_SHEET_MARGIN, height: screen.height - top - safeArea.bottom - FORM_SHEET_MARGIN };
|
|
1815
|
+
const width = Math.max(0, Math.min(FORM_SHEET.width, room.width));
|
|
1816
|
+
const height = Math.max(0, Math.min(FORM_SHEET.height, room.height));
|
|
1817
|
+
return { x: Math.round((screen.width - width) / 2), y: Math.round(top + (room.height - height) / 2), width, height };
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1774
1820
|
/** iOS 26: a sheet whose first detent is a partial one floats inset from the screen edges. */
|
|
1775
1821
|
private sheetFloats(node: LayoutNode): boolean {
|
|
1822
|
+
if (this.sheetIsForm(node)) return false;
|
|
1776
1823
|
const detents = node.props['detents'];
|
|
1777
1824
|
const first = Array.isArray(detents) ? str(detents[0]) : null;
|
|
1778
1825
|
return first === 'medium' || first?.startsWith('height:') === true || first?.startsWith('fraction:') === true;
|
|
@@ -1780,6 +1827,7 @@ export class LayoutPass {
|
|
|
1780
1827
|
|
|
1781
1828
|
/** iOS 26 shows the grabber only for a sheet with more than one detent. */
|
|
1782
1829
|
private sheetHasGrabber(node: LayoutNode): boolean {
|
|
1830
|
+
if (this.sheetIsForm(node)) return false;
|
|
1783
1831
|
const detents = node.props['detents'];
|
|
1784
1832
|
return Array.isArray(detents) && detents.length > 1 && !this.sheetIsFull(node);
|
|
1785
1833
|
}
|
|
@@ -3097,7 +3145,7 @@ export class LayoutPass {
|
|
|
3097
3145
|
const sizes = toolbar.children.map((item) => this.measure(item, itemCtx, { width: null, height: null }));
|
|
3098
3146
|
const placement = str(toolbar.props['placement']) ?? 'trailing';
|
|
3099
3147
|
if (placement === 'leading' || placement === 'trailing') {
|
|
3100
|
-
const slots = sizes.map((s) =>
|
|
3148
|
+
const slots = sizes.map((s, i) => navSlot(toolbar.children[i] as LayoutNode, s.width));
|
|
3101
3149
|
return { toolbar, placement, sizes, slots, total: slots.reduce((sum, w) => sum + w, 0) };
|
|
3102
3150
|
}
|
|
3103
3151
|
const total = sizes.reduce((sum, s) => sum + s.width, 0) + Math.max(0, sizes.length - 1) * NAV_ITEM_SPACING;
|
|
@@ -3125,7 +3173,7 @@ export class LayoutPass {
|
|
|
3125
3173
|
const itemCtx = this.childContext(toolbar, cctx);
|
|
3126
3174
|
return toolbar.children.map((item) => {
|
|
3127
3175
|
const itemSize = this.measure(item, itemCtx, { width: null, height: null });
|
|
3128
|
-
return { toolbar, item, size: itemSize, slot:
|
|
3176
|
+
return { toolbar, item, size: itemSize, slot: navSlot(item, itemSize.width) };
|
|
3129
3177
|
});
|
|
3130
3178
|
});
|
|
3131
3179
|
// Items of one toolbar share its glass capsule; separate toolbars sit 8 apart, as the trailing groups do.
|
|
@@ -3396,7 +3444,11 @@ export class LayoutPass {
|
|
|
3396
3444
|
const itemCtx = this.childContext(toolbar, cctx);
|
|
3397
3445
|
for (const node of toolbar.children) {
|
|
3398
3446
|
const size = this.measure(node, itemCtx, { width: null, height: null });
|
|
3399
|
-
const slot =
|
|
3447
|
+
const slot = !hasText(node)
|
|
3448
|
+
? Math.max(BOTTOM_ICON_SLOT, size.width + 2 * BOTTOM_ICON_PAD_H)
|
|
3449
|
+
: size.width < NAV_GLASS_SIZE
|
|
3450
|
+
? BOTTOM_ICON_SLOT
|
|
3451
|
+
: size.width + 2 * BOTTOM_TEXT_PAD_H;
|
|
3400
3452
|
open.push({ node, toolbar, size, slot });
|
|
3401
3453
|
}
|
|
3402
3454
|
}
|
|
@@ -4056,6 +4108,7 @@ export class LayoutPass {
|
|
|
4056
4108
|
};
|
|
4057
4109
|
const chrome: Record<string, Rect> = { content };
|
|
4058
4110
|
if (floats) chrome['floating'] = { x, y, width: size.width, height: size.height };
|
|
4111
|
+
if (this.sheetIsForm(node)) chrome['form'] = { x, y, width: size.width, height: size.height };
|
|
4059
4112
|
if (grabber) {
|
|
4060
4113
|
chrome['grabber'] = {
|
|
4061
4114
|
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
|
@@ -141,6 +141,7 @@ import {
|
|
|
141
141
|
Crown,
|
|
142
142
|
Database,
|
|
143
143
|
Delete,
|
|
144
|
+
Eraser,
|
|
144
145
|
Diamond,
|
|
145
146
|
Dices,
|
|
146
147
|
Dog,
|
|
@@ -251,6 +252,7 @@ import {
|
|
|
251
252
|
MonitorPlay,
|
|
252
253
|
Moon,
|
|
253
254
|
Mountain,
|
|
255
|
+
MousePointer2,
|
|
254
256
|
Music,
|
|
255
257
|
Navigation2,
|
|
256
258
|
Network,
|
|
@@ -343,6 +345,7 @@ import {
|
|
|
343
345
|
SquareMinus,
|
|
344
346
|
SquareParking,
|
|
345
347
|
SquarePen,
|
|
348
|
+
SquareDashed,
|
|
346
349
|
SquarePlay,
|
|
347
350
|
SquarePlus,
|
|
348
351
|
SquareUser,
|
|
@@ -558,6 +561,10 @@ export const SF_SYMBOLS: Readonly<Record<string, SymbolGlyph>> = {
|
|
|
558
561
|
trash: outline(Trash),
|
|
559
562
|
'trash.fill': outline(Trash),
|
|
560
563
|
pencil: outline(Pencil),
|
|
564
|
+
// The editing tools of a drawing app: the pointer (macOS's arrow), a marquee, the eraser.
|
|
565
|
+
cursorarrow: outline(MousePointer2),
|
|
566
|
+
'square.dashed': outline(SquareDashed),
|
|
567
|
+
eraser: outline(Eraser),
|
|
561
568
|
'square.and.pencil': outline(SquarePen),
|
|
562
569
|
'doc.on.doc': outline(Copy),
|
|
563
570
|
'doc.on.clipboard': outline(Clipboard),
|
|
@@ -1103,13 +1110,13 @@ const GENERIC_SHAPES = new Set(['circle', 'square', 'rectangle', 'app', 'capsule
|
|
|
1103
1110
|
* is drawn as `person.crop.circle`, `trash.circle.fill` as `trash.fill` and
|
|
1104
1111
|
* `speaker.wave.2.fill` as `speaker.wave.2`. A `.badge…` suffix is dropped,
|
|
1105
1112
|
* then trailing parts one at a time; a name with `.fill` tries every filled
|
|
1106
|
-
* form first. A `.slash` variant
|
|
1113
|
+
* form first. A `.slash` variant keeps its slash (`speaker.slash.fill` is
|
|
1114
|
+
* drawn as `speaker.slash`) and is never drawn as its base, which would
|
|
1107
1115
|
* mean the opposite (`bell.slash` is "notifications off"); nor is a name
|
|
1108
1116
|
* drawn as a bare shape. Unresolved names get the caller's placeholder.
|
|
1109
1117
|
*/
|
|
1110
1118
|
function resolveVariant(systemName: string): SymbolGlyph | undefined {
|
|
1111
1119
|
let parts = systemName.split('.');
|
|
1112
|
-
if (parts.includes('slash')) return undefined;
|
|
1113
1120
|
const fill = parts.includes('fill');
|
|
1114
1121
|
parts = parts.filter((part) => part !== 'fill');
|
|
1115
1122
|
const badge = parts.indexOf('badge');
|
|
@@ -1130,7 +1137,9 @@ function resolveVariant(systemName: string): SymbolGlyph | undefined {
|
|
|
1130
1137
|
}
|
|
1131
1138
|
}
|
|
1132
1139
|
const bases: string[] = [];
|
|
1133
|
-
|
|
1140
|
+
// Never shorter than the slash.
|
|
1141
|
+
const shortest = Math.max(1, parts.indexOf('slash') + 1);
|
|
1142
|
+
for (let length = parts.length; length >= shortest; length--) {
|
|
1134
1143
|
const base = parts.slice(0, length).join('.');
|
|
1135
1144
|
if (length === 1 && parts.length > 1 && GENERIC_SHAPES.has(base)) break;
|
|
1136
1145
|
bases.push(base);
|