@swiftbrowser/web 0.18.3 → 0.19.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/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,
@@ -304,6 +305,8 @@ const ALL_KINDS: ReadonlySet<string> = new Set<ElementKind>([
304
305
  // A `Section`'s view header / footer
305
306
  'sectionheader',
306
307
  'sectionfooter',
308
+ // `DisclosureGroup`: its label with a chevron, its content below once expanded
309
+ 'disclosuregroup',
307
310
  // An `AVPlayer` shown by a view (src/video.ts)
308
311
  'video',
309
312
  'lazyspacer',
@@ -320,10 +323,14 @@ const SLIDER_THUMB = 38;
320
323
  export const PICKER_MENU_WIDTH = 250;
321
324
  /** Row height inside the picker menu. */
322
325
  export const PICKER_MENU_ROW = 44;
326
+ /** A row with a subtitle under its title (styles.css, `[data-sb-subtitled]`). */
327
+ const MENU_SUBTITLED_ROW = 58;
323
328
  /** Height of a section header caption row in the popover (Phase 6). */
324
- const MENU_CAPTION_HEIGHT = 32;
325
- /** Thickness of a group separator in the popover (Phase 6). */
326
- const MENU_SEPARATOR_HEIGHT = 8;
329
+ const MENU_CAPTION_HEIGHT = 30;
330
+ /** A group separator in the popover: a hairline with 9px above and below (iOS 26). */
331
+ const MENU_SEPARATOR_HEIGHT = 19;
332
+ /** The popover's padding above its first row and below its last (iOS 26's inset menu); keep in sync with styles.css. */
333
+ const MENU_PADDING = 10;
327
334
  /** Gap between the control and its menu. */
328
335
  const PICKER_MENU_GAP = 4;
329
336
  /** Minimum distance between the menu and the screen edge. */
@@ -515,16 +522,24 @@ export class Renderer {
515
522
  // `tapgesture` (or a menu label) handles the click alone. Elsewhere in
516
523
  // a list row, the row's one button (`rowAction`).
517
524
  const hit = event.target as HTMLElement | null;
518
- const target = hit?.closest<HTMLElement>(
525
+ let target = hit?.closest<HTMLElement>(
519
526
  '.sb-button, .sb-navlink, .sb-navbar-back, .sb-navbar-more, .sb-navsplit-toggle, .sb-switch, .sb-sheet-scrim, ' +
520
527
  '.sb-toggle[data-sb-toggle-style="button"], .sb-toolbar .sb-toggle:not([data-sb-toggle-style]), ' +
521
528
  '.sb-tabbar-item, .sb-segment, .sb-picker-option, .sb-picker-menu-item, .sb-picker[data-sb-style="menu"], ' +
522
529
  '.sb-menu, .sb-tapgesture, .sb-stepper-button, .sb-alert-scrim, .sb-alert-ok, ' +
523
530
  // Phase 8: swipe action buttons, the edit-mode row control and selectable rows (the row sends `tap` itself)
524
531
  '.sb-swipe-action, .sb-listrow-control, .sb-listrow[data-sb-selectable], ' +
532
+ // A disclosure group's label, a collapsible section's header: a `tap` on the element toggles it
533
+ '.sb-disclosure-toggle, .sb-section[data-sb-collapsible] > .sb-section-header, .sb-section[data-sb-collapsible] .sb-sectionheader, ' +
525
534
  // A link in a rich text: innermost, so it wins over a tappable row around the text
526
535
  '.sb-link',
527
536
  ) ?? (hit ? rowAction(hit, this.root) : null);
537
+ // A link or button row of a list in edit mode is selected, not followed
538
+ // or pressed: the tap is the row's (its `tag`), as iOS selects the row then.
539
+ if (target?.classList.contains('sb-navlink') || target?.classList.contains('sb-button')) {
540
+ const row = target.closest<HTMLElement>('.sb-listrow[data-sb-selectable]');
541
+ if (row && row.closest('.sb-list')?.hasAttribute('data-sb-editing')) target = row;
542
+ }
528
543
  if (!target || !this.root.contains(target)) return;
529
544
  if (target.classList.contains('sb-link')) {
530
545
  const text = target.closest<HTMLElement>('.sb-text');
@@ -535,6 +550,19 @@ export class Renderer {
535
550
  this.onEvent({ type: 'link', id, value: url });
536
551
  return;
537
552
  }
553
+ const sectionHeader = target.classList.contains('sb-section-header') || target.classList.contains('sb-sectionheader');
554
+ if (target.classList.contains('sb-disclosure-toggle') || sectionHeader) {
555
+ // `DisclosureGroup` and `Section(isExpanded:)`: the tap toggles the
556
+ // element (a section's only in a sidebar-style list, where it collapses).
557
+ const section = sectionHeader;
558
+ const owner = target.closest<HTMLElement>(section ? '.sb-section' : '.sb-disclosuregroup');
559
+ const id = Number(owner?.dataset[ID_ATTR]);
560
+ if (!owner || !Number.isInteger(id)) return;
561
+ if (section && owner.closest<HTMLElement>('.sb-list')?.dataset['sbListStyle'] !== 'sidebar') return;
562
+ event.preventDefault();
563
+ this.onEvent({ type: 'tap', id });
564
+ return;
565
+ }
538
566
  if (this.interaction.handleClick(target)) {
539
567
  event.preventDefault();
540
568
  return;
@@ -588,7 +616,7 @@ export class Renderer {
588
616
  return;
589
617
  }
590
618
  if (target.classList.contains('sb-picker-menu-item')) {
591
- // A popover row: a picker option (`select`) or a button / navlink (`tap`).
619
+ // A popover row: a picker option (`select`), a toggle, or a button / navlink (`tap`).
592
620
  if (!this.menu) return;
593
621
  const action = target.dataset['sbAction'];
594
622
  const id = Number(target.dataset['sbTarget']);
@@ -597,6 +625,7 @@ export class Renderer {
597
625
  event.preventDefault();
598
626
  this.closeMenu();
599
627
  if (action === 'tap') this.onEvent({ type: 'tap', id });
628
+ else if (action === 'toggle') this.onEvent({ type: 'toggle', id, value: target.getAttribute('aria-checked') !== 'true' });
600
629
  else if (Number.isInteger(index)) this.onEvent({ type: 'select', id, value: index });
601
630
  return;
602
631
  }
@@ -1691,6 +1720,11 @@ export class Renderer {
1691
1720
  this.collectLayouts(result);
1692
1721
  if (this.applying === 0) this.flushGeometry();
1693
1722
  this.queueVisibilityCheck();
1723
+ // `.statusBarHidden` on a screen that shows: the frame's drawn status bar fades (styles.css).
1724
+ const statusHidden = this.root.querySelector('.sb-navscreen[data-sb-status-hidden]:not([hidden])') !== null;
1725
+ if (document.documentElement.hasAttribute('data-sb-status-hidden') !== statusHidden) {
1726
+ document.documentElement.toggleAttribute('data-sb-status-hidden', statusHidden);
1727
+ }
1694
1728
  this.afterLayout?.();
1695
1729
  return changes;
1696
1730
  }
@@ -1818,10 +1852,23 @@ export class Renderer {
1818
1852
  const menu = div('sb-picker-menu');
1819
1853
  menu.setAttribute('role', 'menu');
1820
1854
  menu.dataset['sbFor'] = String(id);
1821
- let height = 0;
1855
+ // No hairline at either end (a section first or last).
1856
+ while (rows[0]?.kind === 'separator') rows = rows.slice(1);
1857
+ while (rows[rows.length - 1]?.kind === 'separator') rows = rows.slice(0, -1);
1858
+ const checks = rows.some((row) => row.kind === 'select' || row.kind === 'toggle');
1859
+ // iOS indents the whole menu around its check column: section titles start with the plain titles.
1860
+ if (checks) menu.dataset['sbChecks'] = '';
1861
+ let height = 2 * MENU_PADDING;
1822
1862
  for (const row of rows) {
1823
- menu.append(menuRowElement(row));
1824
- height += row.kind === 'caption' ? MENU_CAPTION_HEIGHT : row.kind === 'separator' ? MENU_SEPARATOR_HEIGHT : PICKER_MENU_ROW;
1863
+ menu.append(menuRowElement(row, checks));
1864
+ height +=
1865
+ row.kind === 'caption'
1866
+ ? MENU_CAPTION_HEIGHT
1867
+ : row.kind === 'separator'
1868
+ ? MENU_SEPARATOR_HEIGHT
1869
+ : (row.kind === 'tap' || row.kind === 'toggle') && row.subtitle
1870
+ ? MENU_SUBTITLED_ROW
1871
+ : PICKER_MENU_ROW;
1825
1872
  }
1826
1873
  // Anchor to the control: trailing edges aligned, below it when there is
1827
1874
  // room, else above; clamped inside the screen. A menu taller than the
@@ -1832,7 +1879,10 @@ export class Renderer {
1832
1879
  const width = Math.min(PICKER_MENU_WIDTH, Math.max(0, rootRect.width - 2 * PICKER_MENU_MARGIN));
1833
1880
  let left = anchorRect.right - rootRect.left - width;
1834
1881
  left = Math.max(PICKER_MENU_MARGIN, Math.min(left, rootRect.width - width - PICKER_MENU_MARGIN));
1835
- const belowTop = anchorRect.bottom - rootRect.top + PICKER_MENU_GAP;
1882
+ // A toolbar button's menu grows out of the button and covers it (iOS 26);
1883
+ // any other control's opens just under it.
1884
+ const inToolbar = anchor.closest('.sb-toolbar') !== null;
1885
+ const belowTop = inToolbar ? anchorRect.top - rootRect.top : anchorRect.bottom - rootRect.top + PICKER_MENU_GAP;
1836
1886
  const roomBelow = rootRect.height - PICKER_MENU_MARGIN - belowTop;
1837
1887
  const roomAbove = anchorRect.top - rootRect.top - PICKER_MENU_GAP - PICKER_MENU_MARGIN;
1838
1888
  const below = height <= roomBelow || (height > roomAbove && roomBelow >= roomAbove);
@@ -2684,7 +2734,8 @@ interface OpenMenu {
2684
2734
 
2685
2735
  /** One row of a popover menu. */
2686
2736
  type MenuRow =
2687
- | { kind: 'tap'; id: ElementId; title: string; symbol: string | null; destructive: boolean; disabled: boolean }
2737
+ | { kind: 'tap'; id: ElementId; title: string; subtitle: string | null; symbol: string | null; destructive: boolean; disabled: boolean }
2738
+ | { kind: 'toggle'; id: ElementId; title: string; subtitle: string | null; symbol: string | null; isOn: boolean; disabled: boolean }
2688
2739
  | { kind: 'select'; id: ElementId; index: number; title: string; checked: boolean }
2689
2740
  | { kind: 'caption'; text: string }
2690
2741
  | { kind: 'text'; text: string }
@@ -2704,6 +2755,20 @@ function disabledOf(node: LayoutNode): boolean {
2704
2755
  return false;
2705
2756
  }
2706
2757
 
2758
+ /**
2759
+ * A menu item's title and subtitle, as SwiftUI reads a menu label: its
2760
+ * first text is the title, any after it the subtitle under it.
2761
+ */
2762
+ function titlesOf(node: LayoutNode): { title: string; subtitle: string | null } {
2763
+ const texts: string[] = [];
2764
+ const visit = (n: LayoutNode): void => {
2765
+ if (n.kind === 'text' && typeof n.props['text'] === 'string' && n.props['text'].trim().length > 0) texts.push(n.props['text'].trim());
2766
+ n.children.forEach(visit);
2767
+ };
2768
+ visit(node);
2769
+ return { title: texts[0] ?? '', subtitle: texts.length > 1 ? texts.slice(1).join(' ') : null };
2770
+ }
2771
+
2707
2772
  /**
2708
2773
  * Turn a `menu`'s items into popover rows. A picker's options form their
2709
2774
  * own group, separated from neighbouring rows like iOS does; explicit
@@ -2726,12 +2791,16 @@ function collectMenuRows(items: readonly LayoutNode[], rows: MenuRow[] = []): Me
2726
2791
  rows.push({
2727
2792
  kind: 'tap',
2728
2793
  id: base.id,
2729
- title: textOf(base),
2794
+ ...titlesOf(base),
2730
2795
  symbol: symbolOf(base),
2731
2796
  destructive: base.props['role'] === 'destructive',
2732
2797
  disabled: disabledOf(item),
2733
2798
  });
2734
2799
  break;
2800
+ case 'toggle':
2801
+ // A toggle in a menu is a checkable row (SwiftUI's), its label the title.
2802
+ rows.push({ kind: 'toggle', id: base.id, ...titlesOf(base), symbol: symbolOf(base), isOn: base.props['isOn'] === true, disabled: disabledOf(item) });
2803
+ break;
2735
2804
  case 'picker': {
2736
2805
  const raw = base.props['options'];
2737
2806
  const options = Array.isArray(raw) ? raw.map(String) : [];
@@ -2745,9 +2814,12 @@ function collectMenuRows(items: readonly LayoutNode[], rows: MenuRow[] = []): Me
2745
2814
  separate();
2746
2815
  break;
2747
2816
  case 'section': {
2817
+ // An inline section, as iOS draws one: a hairline before and after it, its header a caption.
2818
+ separate();
2748
2819
  const header = base.props['header'];
2749
2820
  if (typeof header === 'string' && header.length > 0) rows.push({ kind: 'caption', text: header });
2750
2821
  collectMenuRows(base.children, rows);
2822
+ pendingSeparator = true;
2751
2823
  break;
2752
2824
  }
2753
2825
  case 'sectionheader': {
@@ -2777,44 +2849,60 @@ function collectMenuRows(items: readonly LayoutNode[], rows: MenuRow[] = []): Me
2777
2849
  return rows;
2778
2850
  }
2779
2851
 
2780
- /** Build the DOM for one popover row (the renderer's click handler reads the data attributes). */
2781
- function menuRowElement(row: MenuRow): HTMLElement {
2852
+ /**
2853
+ * Build the DOM for one popover row (the renderer's click handler reads the
2854
+ * data attributes). In a menu with checkable rows (`checks`), every item row
2855
+ * keeps a leading column for the checkmark, as iOS does, so titles line up;
2856
+ * an item's symbol follows the check, its subtitle sits under its title.
2857
+ */
2858
+ function menuRowElement(row: MenuRow, checks: boolean): HTMLElement {
2782
2859
  switch (row.kind) {
2783
- case 'select': {
2784
- const item = document.createElement('button');
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
- }
2860
+ case 'select':
2861
+ case 'toggle':
2800
2862
  case 'tap': {
2801
2863
  const item = document.createElement('button');
2802
2864
  item.type = 'button';
2803
2865
  item.className = 'sb-picker-menu-item';
2804
- item.setAttribute('role', 'menuitem');
2805
- item.dataset['sbAction'] = 'tap';
2806
2866
  item.dataset['sbTarget'] = String(row.id);
2807
- if (row.destructive) item.dataset['sbRole'] = 'destructive';
2808
- if (row.disabled) item.disabled = true;
2867
+ item.dataset['sbAction'] = row.kind;
2868
+ if (row.kind === 'select') {
2869
+ item.setAttribute('role', 'menuitemradio');
2870
+ item.setAttribute('aria-checked', String(row.checked));
2871
+ item.dataset['sbIndex'] = String(row.index);
2872
+ } else if (row.kind === 'toggle') {
2873
+ item.setAttribute('role', 'menuitemcheckbox');
2874
+ item.setAttribute('aria-checked', String(row.isOn));
2875
+ } else {
2876
+ item.setAttribute('role', 'menuitem');
2877
+ if (row.destructive) item.dataset['sbRole'] = 'destructive';
2878
+ }
2879
+ if (row.kind !== 'select' && row.disabled) item.disabled = true;
2880
+ if (checks) {
2881
+ const check = span('sb-picker-menu-check');
2882
+ const glyph = renderSymbol('checkmark');
2883
+ if (glyph) check.append(glyph);
2884
+ item.append(check);
2885
+ }
2886
+ const symbol = row.kind === 'select' ? null : row.symbol;
2887
+ if (symbol) {
2888
+ const holder = span('sb-picker-menu-symbol');
2889
+ const glyph = renderSymbol(symbol);
2890
+ if (glyph) holder.append(glyph);
2891
+ holder.dataset['sbSymbol'] = symbol;
2892
+ item.append(holder);
2893
+ }
2809
2894
  const title = span('sb-picker-menu-title');
2810
2895
  title.textContent = row.title;
2811
- item.append(title);
2812
- if (row.symbol) {
2813
- const symbol = span('sb-picker-menu-symbol');
2814
- const glyph = renderSymbol(row.symbol);
2815
- if (glyph) symbol.append(glyph);
2816
- symbol.dataset['sbSymbol'] = row.symbol;
2817
- item.append(symbol);
2896
+ const subtitle = row.kind === 'select' ? null : row.subtitle;
2897
+ if (subtitle) {
2898
+ const titles = span('sb-picker-menu-titles');
2899
+ const sub = span('sb-picker-menu-subtitle');
2900
+ sub.textContent = subtitle;
2901
+ titles.append(title, sub);
2902
+ item.append(titles);
2903
+ item.dataset['sbSubtitled'] = '';
2904
+ } else {
2905
+ item.append(title);
2818
2906
  }
2819
2907
  return item;
2820
2908
  }
@@ -2837,6 +2925,11 @@ function menuRowElement(row: MenuRow): HTMLElement {
2837
2925
  }
2838
2926
  }
2839
2927
 
2928
+ /** Whether scrolling dismisses the keyboard; a page cannot drag it down, so `interactively` blurs as `immediately` does. */
2929
+ function dismissesKeyboard(mode: unknown): boolean {
2930
+ return mode === true || mode === 'immediately' || mode === 'interactively';
2931
+ }
2932
+
2840
2933
  /** Props as the engine reads them (untrusted JSON, so a plain record). */
2841
2934
  function asRecord(props: unknown): Record<string, unknown> {
2842
2935
  return typeof props === 'object' && props !== null ? (props as Record<string, unknown>) : {};
@@ -2973,6 +3066,25 @@ function createShell(kind: ElementKind): HTMLElement {
2973
3066
  SLOTS.set(el, slot);
2974
3067
  return el;
2975
3068
  }
3069
+ case 'disclosuregroup': {
3070
+ // The label (the first child) and the content below it, laid out as
3071
+ // a stack; a button over the label and a chevron at its trailing
3072
+ // edge, placed by layoutDom from the label's frame.
3073
+ const el = document.createElement('div');
3074
+ const slot = div('sb-slot');
3075
+ const toggle = document.createElement('button');
3076
+ toggle.type = 'button';
3077
+ toggle.className = 'sb-disclosure-toggle';
3078
+ toggle.setAttribute('aria-expanded', 'false');
3079
+ el.append(slot, toggle);
3080
+ const chevron = renderSymbol('chevron.right');
3081
+ if (chevron) {
3082
+ chevron.classList.add('sb-disclosure-chevron');
3083
+ el.append(chevron);
3084
+ }
3085
+ SLOTS.set(el, slot);
3086
+ return el;
3087
+ }
2976
3088
  case 'toggle': {
2977
3089
  const el = document.createElement('div');
2978
3090
  const label = div('sb-slot sb-toggle-label');
@@ -3828,7 +3940,7 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
3828
3940
  applyRefreshable(el, list.refreshable === true, list.refreshing === true);
3829
3941
  // `.scrollContentBackground(.hidden)`: no grouped background (styles.css).
3830
3942
  if (list.background === 'hidden') el.dataset['sbBackground'] = 'hidden';
3831
- if (list.dismissesKeyboard === true) el.dataset['sbDismissesKeyboard'] = 'true';
3943
+ if (dismissesKeyboard(list.dismissesKeyboard)) el.dataset['sbDismissesKeyboard'] = 'true';
3832
3944
  applyEdgeEffect(el, list.edgeEffect);
3833
3945
  break;
3834
3946
  }
@@ -3859,7 +3971,7 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
3859
3971
  applyRefreshable(el, scroll.refreshable === true, scroll.refreshing === true);
3860
3972
  if (scroll.snap === 'viewAligned' || scroll.snap === 'paging') el.dataset['sbSnap'] = scroll.snap;
3861
3973
  else delete el.dataset['sbSnap'];
3862
- if (scroll.dismissesKeyboard === true) el.dataset['sbDismissesKeyboard'] = 'true';
3974
+ if (dismissesKeyboard(scroll.dismissesKeyboard)) el.dataset['sbDismissesKeyboard'] = 'true';
3863
3975
  if (scroll.reportsPosition === true) el.dataset['sbReportsPosition'] = '';
3864
3976
  else delete el.dataset['sbReportsPosition'];
3865
3977
  applyEdgeEffect(el, scroll.edgeEffect);
@@ -3869,6 +3981,12 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
3869
3981
  case 'section':
3870
3982
  applySection(el, props as SectionProps);
3871
3983
  break;
3984
+ case 'disclosuregroup': {
3985
+ const expanded = (props as DisclosureGroupProps).expanded === true;
3986
+ el.toggleAttribute('data-sb-expanded', expanded);
3987
+ el.querySelector<HTMLElement>(':scope > .sb-disclosure-toggle')?.setAttribute('aria-expanded', String(expanded));
3988
+ break;
3989
+ }
3872
3990
  case 'textfield':
3873
3991
  applyTextField(el as HTMLInputElement | HTMLTextAreaElement, props as TextFieldProps);
3874
3992
  break;
@@ -4409,6 +4527,10 @@ function applySection(el: HTMLElement, props: SectionProps): void {
4409
4527
  header.textContent = props.header ?? '';
4410
4528
  header.hidden = !props.header;
4411
4529
  }
4530
+ // `Section(isExpanded:)`: a chevron on the header (a string one, or the
4531
+ // `sectionheader` view) in a sidebar-style list (styles.css); its tap toggles.
4532
+ el.toggleAttribute('data-sb-collapsible', props.collapsible === true);
4533
+ el.toggleAttribute('data-sb-expanded', props.collapsible === true && props.expanded !== false);
4412
4534
  if (footer) {
4413
4535
  footer.textContent = props.footer ?? '';
4414
4536
  footer.hidden = !props.footer;
@@ -4864,6 +4986,8 @@ export function applyStyle(el: HTMLElement, style: Style): void {
4864
4986
  if (style.animationToken != null) el.dataset[ANIMATION_TOKEN] = String(style.animationToken);
4865
4987
  // A `Label`'s icon: tinted in list rows (styles.css).
4866
4988
  if (style.labelIcon) el.dataset['sbLabelIcon'] = '';
4989
+ // `.textSelection(.enabled)`: the text below can be selected (styles.css).
4990
+ el.toggleAttribute('data-sb-text-selection', style.textSelection === true);
4867
4991
  if (style.animation) ANIMATIONS.set(el, style.animation);
4868
4992
  if (style.transition) setTransition(el, style.transition);
4869
4993