@oxy.so/bloom 4.2.0 → 4.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/docs/adoption-matrix.mdx +8 -8
  2. package/docs/ai-chat.mdx +20 -1
  3. package/docs/settings-modal.mdx +7 -1
  4. package/docs/sidebar.mdx +11 -0
  5. package/lib/commonjs/ai-chat/AiChatContainer.js +8 -8
  6. package/lib/commonjs/ai-chat/AiChatContainer.js.map +1 -1
  7. package/lib/commonjs/ai-chat/AiChatShell.js +13 -8
  8. package/lib/commonjs/ai-chat/AiChatShell.js.map +1 -1
  9. package/lib/commonjs/ai-chat/use-panel-interaction.js +75 -0
  10. package/lib/commonjs/ai-chat/use-panel-interaction.js.map +1 -0
  11. package/lib/commonjs/overlay/stack.js +6 -0
  12. package/lib/commonjs/overlay/stack.js.map +1 -1
  13. package/lib/commonjs/settings-modal/SettingsModal.js +120 -112
  14. package/lib/commonjs/settings-modal/SettingsModal.js.map +1 -1
  15. package/lib/commonjs/sidebar/Sidebar.js +34 -4
  16. package/lib/commonjs/sidebar/Sidebar.js.map +1 -1
  17. package/lib/module/ai-chat/AiChatContainer.js +8 -8
  18. package/lib/module/ai-chat/AiChatContainer.js.map +1 -1
  19. package/lib/module/ai-chat/AiChatShell.js +13 -8
  20. package/lib/module/ai-chat/AiChatShell.js.map +1 -1
  21. package/lib/module/ai-chat/use-panel-interaction.js +71 -0
  22. package/lib/module/ai-chat/use-panel-interaction.js.map +1 -0
  23. package/lib/module/overlay/stack.js +5 -0
  24. package/lib/module/overlay/stack.js.map +1 -1
  25. package/lib/module/settings-modal/SettingsModal.js +123 -115
  26. package/lib/module/settings-modal/SettingsModal.js.map +1 -1
  27. package/lib/module/sidebar/Sidebar.js +34 -4
  28. package/lib/module/sidebar/Sidebar.js.map +1 -1
  29. package/lib/typescript/commonjs/ai-chat/AiChatShell.d.ts +1 -1
  30. package/lib/typescript/commonjs/ai-chat/AiChatShell.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/ai-chat/types.d.ts +4 -4
  32. package/lib/typescript/commonjs/ai-chat/types.d.ts.map +1 -1
  33. package/lib/typescript/commonjs/ai-chat/use-panel-interaction.d.ts +4 -0
  34. package/lib/typescript/commonjs/ai-chat/use-panel-interaction.d.ts.map +1 -0
  35. package/lib/typescript/commonjs/overlay/stack.d.ts +2 -0
  36. package/lib/typescript/commonjs/overlay/stack.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/settings-modal/SettingsModal.d.ts +1 -1
  38. package/lib/typescript/commonjs/settings-modal/SettingsModal.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/settings-modal/types.d.ts +3 -0
  40. package/lib/typescript/commonjs/settings-modal/types.d.ts.map +1 -1
  41. package/lib/typescript/commonjs/sidebar/Sidebar.d.ts.map +1 -1
  42. package/lib/typescript/commonjs/sidebar/types.d.ts +12 -1
  43. package/lib/typescript/commonjs/sidebar/types.d.ts.map +1 -1
  44. package/lib/typescript/module/ai-chat/AiChatShell.d.ts +1 -1
  45. package/lib/typescript/module/ai-chat/AiChatShell.d.ts.map +1 -1
  46. package/lib/typescript/module/ai-chat/types.d.ts +4 -4
  47. package/lib/typescript/module/ai-chat/types.d.ts.map +1 -1
  48. package/lib/typescript/module/ai-chat/use-panel-interaction.d.ts +4 -0
  49. package/lib/typescript/module/ai-chat/use-panel-interaction.d.ts.map +1 -0
  50. package/lib/typescript/module/overlay/stack.d.ts +2 -0
  51. package/lib/typescript/module/overlay/stack.d.ts.map +1 -1
  52. package/lib/typescript/module/settings-modal/SettingsModal.d.ts +1 -1
  53. package/lib/typescript/module/settings-modal/SettingsModal.d.ts.map +1 -1
  54. package/lib/typescript/module/settings-modal/types.d.ts +3 -0
  55. package/lib/typescript/module/settings-modal/types.d.ts.map +1 -1
  56. package/lib/typescript/module/sidebar/Sidebar.d.ts.map +1 -1
  57. package/lib/typescript/module/sidebar/types.d.ts +12 -1
  58. package/lib/typescript/module/sidebar/types.d.ts.map +1 -1
  59. package/package.json +1 -1
  60. package/src/__tests__/support/adoption-matrix.ts +703 -0
  61. package/src/__tests__/support/collision-fixture-barrel.ts +20 -0
  62. package/src/__tests__/support/commerce-harness.tsx +97 -0
  63. package/src/__tests__/support/composite.ts +68 -0
  64. package/src/__tests__/support/constructed-style-sheets.ts +68 -0
  65. package/src/__tests__/support/press-host.ts +30 -0
  66. package/src/__tests__/support/rendered-style.ts +99 -0
  67. package/src/__tests__/support/unread-hook-fixture.ts +33 -0
  68. package/src/ai-chat/AiChatContainer.tsx +7 -7
  69. package/src/ai-chat/AiChatShell.tsx +11 -5
  70. package/src/ai-chat/types.ts +4 -4
  71. package/src/ai-chat/use-panel-interaction.ts +71 -0
  72. package/src/overlay/stack.ts +5 -0
  73. package/src/settings-modal/SettingsModal.tsx +150 -132
  74. package/src/settings-modal/types.ts +3 -0
  75. package/src/sidebar/Sidebar.tsx +25 -4
  76. package/src/sidebar/types.ts +12 -1
  77. package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +9346 -0
  78. package/src/theme/__tests__/__fixtures__/tonal-glass-primary-failures.json +69 -0
  79. package/src/theme/__tests__/fixtures/color-engine-golden.json +1 -0
@@ -6,19 +6,12 @@ import React, {
6
6
  useRef,
7
7
  useState,
8
8
  } from 'react';
9
- import {
10
- BackHandler,
11
- Pressable,
12
- ScrollView,
13
- StyleSheet,
14
- View,
15
- useWindowDimensions,
16
- type NativeScrollEvent,
17
- type NativeSyntheticEvent,
18
- } from 'react-native';
9
+ import { BackHandler, Pressable, StyleSheet, View, useWindowDimensions } from 'react-native';
19
10
  import Animated, {
20
11
  Easing,
21
12
  useAnimatedStyle,
13
+ useAnimatedScrollHandler,
14
+ type SharedValue,
22
15
  useReducedMotion,
23
16
  useSharedValue,
24
17
  withTiming,
@@ -31,7 +24,6 @@ import { RiCheckboxCircleFill } from '../icons/remix/RiCheckboxCircleFill';
31
24
  import { PageHeader } from '../page-header';
32
25
  import { ButtonGroup, ButtonGroupItem } from '../button-group';
33
26
  import { RiCloseLine } from '../icons/remix/RiCloseLine';
34
- import { VerticalFade } from './SettingsArt';
35
27
  import { Backdrop, OverlayRoot } from '../overlay';
36
28
  import type { WebCssStyle } from '../styles/web-view-style';
37
29
  import { webDataSet } from '../styles/web-data';
@@ -59,8 +51,8 @@ import { IS_WEB, useSettingsWebCss } from './web-css';
59
51
  * 20px icon (icon/secondary) + 8 + body-medium label
60
52
  * (selected: background/secondary/hover + text/primary;
61
53
  * hover: background/secondary/hover @ 60%)
62
- * content shared inline PageHeader and close ButtonGroup island; the page scrolls under a 40px top fade that eases
63
- * in (200ms) once it is scrolled; px 32 pb 32
54
+ * content one viewport with a sticky PageHeader and its scroll-linked edge;
55
+ * content insets belong to the page body, never the viewport
64
56
  * toast "Saved" pill straddling the panel's bottom edge: 1px
65
57
  * border/button, background/primary, py 4 pr 10 pl 6, gap 4,
66
58
  * 16px lime check + body-2-medium, shadow-dropdown. Rises 12px in,
@@ -87,7 +79,11 @@ const RAIL_WIDTH = 274;
87
79
  /** `medium` layout: the rail narrows so the page keeps a readable measure. */
88
80
  const RAIL_WIDTH_MEDIUM = 220;
89
81
  /** Content inset per layout — 32 at regular, stepping down with the room. */
90
- const CONTENT_INSET: Record<SettingsModalLayout, number> = { regular: 32, medium: 24, compact: 16 };
82
+ const CONTENT_INSET: Record<SettingsModalLayout, number> = {
83
+ regular: 32,
84
+ medium: 24,
85
+ compact: 16,
86
+ };
91
87
  const VIEWPORT_GUTTER = 16;
92
88
  const MOTION_MS = 300;
93
89
  const UNMOUNT_MS = 320;
@@ -127,6 +123,7 @@ export function SettingsModal({
127
123
  control,
128
124
  open: controlledOpen,
129
125
  onClose,
126
+ onBeforeLeave,
130
127
  groups,
131
128
  pages,
132
129
  page: controlledPage,
@@ -160,6 +157,11 @@ export function SettingsModal({
160
157
  const initialViewRef = useRef(initialView);
161
158
  initialViewRef.current = initialView;
162
159
 
160
+ const beforeLeaveRef = useRef(onBeforeLeave);
161
+ beforeLeaveRef.current = onBeforeLeave;
162
+ const mayLeave = useCallback((reason: 'close' | 'navigation' | 'page') =>
163
+ beforeLeaveRef.current?.(reason) !== false, []);
164
+
163
165
  const onCloseRef = useRef(onClose);
164
166
  onCloseRef.current = onClose;
165
167
  const isControlledRef = useRef(isControlled);
@@ -212,31 +214,37 @@ export function SettingsModal({
212
214
  }, [isControlled, controlledOpen, show, hide]);
213
215
 
214
216
  const requestClose = useCallback(() => {
217
+ if (!mayLeave('close')) return false;
215
218
  if (isControlledRef.current) onCloseRef.current?.();
216
219
  else hide();
217
- }, [hide]);
220
+ return true;
221
+ }, [hide, mayLeave]);
218
222
 
219
223
  useImperativeHandle(
220
224
  control?.ref,
221
225
  () => ({
222
226
  open: show,
223
227
  close: (cb?: () => void) => {
224
- hide();
225
- if (cb) setTimeout(cb, UNMOUNT_MS);
228
+ if (requestClose() && cb) setTimeout(cb, UNMOUNT_MS);
226
229
  },
227
230
  }),
228
- [show, hide],
231
+ [show, requestClose],
229
232
  );
230
233
 
231
234
  const selectPage = useCallback(
232
235
  (next: string) => {
236
+ if (next !== currentPage && !mayLeave('page')) return;
233
237
  if (controlledPage === undefined) setInternalPage(next);
234
238
  setCompactPageOpen(true);
235
239
  onPageChange?.(next);
236
240
  },
237
- [controlledPage, onPageChange],
241
+ [controlledPage, currentPage, onPageChange, mayLeave],
238
242
  );
239
243
 
244
+ const requestNavigation = useCallback(() => {
245
+ if (mayLeave('navigation')) setCompactPageOpen(false);
246
+ }, [mayLeave]);
247
+
240
248
  // Escape closes (web). On `window`, not `document`: a menu or popover opened
241
249
  // inside the modal handles Escape on `document` and stops propagation there,
242
250
  // so the innermost surface closes first and the modal stays open.
@@ -261,12 +269,12 @@ export function SettingsModal({
261
269
  if (!mounted || IS_WEB || typeof BackHandler?.addEventListener !== 'function') return;
262
270
  const sub = BackHandler.addEventListener('hardwareBackPress', () => {
263
271
  // On a pushed page (compact), back returns to the section list first.
264
- if (compactPageOpenRef.current) setCompactPageOpen(false);
272
+ if (compactPageOpenRef.current) requestNavigation();
265
273
  else requestClose();
266
274
  return true;
267
275
  });
268
276
  return () => sub.remove();
269
- }, [mounted, requestClose]);
277
+ }, [mounted, requestClose, requestNavigation]);
270
278
 
271
279
  // ----- saved toast -------------------------------------------------------
272
280
  const [savedPhase, setSavedPhase] = useState<SavedPhase>('hidden');
@@ -328,9 +336,7 @@ export function SettingsModal({
328
336
  // Full screen, a .85 scale reads as the page shrinking away; compact rises 16px instead.
329
337
  const webPanelMotion: WebCssStyle = {
330
338
  opacity: visible ? 1 : 0,
331
- transform: isCompact
332
- ? [{ translateY: visible ? 0 : 16 }]
333
- : [{ scale: visible ? 1 : 0.85 }],
339
+ transform: isCompact ? [{ translateY: visible ? 0 : 16 }] : [{ scale: visible ? 1 : 0.85 }],
334
340
  filter: visible ? 'blur(0px)' : 'blur(4px)',
335
341
  transitionProperty: 'opacity, transform, filter',
336
342
  transitionDuration: reducedMotion ? '0ms' : `${MOTION_MS}ms`,
@@ -400,16 +406,14 @@ export function SettingsModal({
400
406
  {layout === 'compact' ? (
401
407
  compactPageOpen && pageConfig ? (
402
408
  <View style={styles.content}>
403
- <SettingsHeader
404
- title={pageConfig.title}
405
- onBack={() => setCompactPageOpen(false)}
406
- backLabel={labels?.back ?? 'Back'}
407
- closeLabel={closeLabel}
408
- onClose={requestClose}
409
- palette={palette}
410
- testID={testID}
411
- />
412
409
  <PageScroller
410
+ header={{
411
+ title: pageConfig.title,
412
+ onBack: requestNavigation,
413
+ backLabel: labels?.back ?? 'Back',
414
+ closeLabel,
415
+ onClose: requestClose,
416
+ }}
413
417
  key={currentPage}
414
418
  palette={palette}
415
419
  inset={CONTENT_INSET.compact}
@@ -421,14 +425,12 @@ export function SettingsModal({
421
425
  </View>
422
426
  ) : (
423
427
  <View style={styles.content}>
424
- <SettingsHeader
425
- title={labels?.dialog ?? 'Settings'}
426
- closeLabel={closeLabel}
427
- onClose={requestClose}
428
- palette={palette}
429
- testID={testID}
430
- />
431
428
  <SettingsRail
429
+ header={{
430
+ title: labels?.dialog ?? 'Settings',
431
+ closeLabel,
432
+ onClose: requestClose,
433
+ }}
432
434
  groups={groups}
433
435
  page={currentPage}
434
436
  onSelect={selectPage}
@@ -451,14 +453,12 @@ export function SettingsModal({
451
453
  testID={testID}
452
454
  />
453
455
  <View style={styles.content}>
454
- <SettingsHeader
455
- title={pageConfig?.title ?? ''}
456
- closeLabel={closeLabel}
457
- onClose={requestClose}
458
- palette={palette}
459
- testID={testID}
460
- />
461
456
  <PageScroller
457
+ header={{
458
+ title: pageConfig?.title ?? '',
459
+ closeLabel,
460
+ onClose: requestClose,
461
+ }}
462
462
  key={currentPage}
463
463
  palette={palette}
464
464
  inset={CONTENT_INSET[layout]}
@@ -489,7 +489,10 @@ export function SettingsModal({
489
489
 
490
490
  function ringVars(palette: SettingsPalette): WebCssStyle | null {
491
491
  return IS_WEB
492
- ? { '--bloom-settings-ring': palette.ring, '--bloom-settings-ring-offset': palette.full }
492
+ ? {
493
+ '--bloom-settings-ring': palette.ring,
494
+ '--bloom-settings-ring-offset': palette.full,
495
+ }
493
496
  : null;
494
497
  }
495
498
 
@@ -498,6 +501,7 @@ function ringVars(palette: SettingsPalette): WebCssStyle | null {
498
501
  // ---------------------------------------------------------------------------
499
502
 
500
503
  function SettingsRail({
504
+ header,
501
505
  groups,
502
506
  page,
503
507
  onSelect,
@@ -506,6 +510,7 @@ function SettingsRail({
506
510
  layout,
507
511
  testID,
508
512
  }: {
513
+ header?: SettingsHeaderProps;
509
514
  groups: SettingsNavGroup[];
510
515
  page: string | undefined;
511
516
  onSelect: (page: string) => void;
@@ -515,42 +520,68 @@ function SettingsRail({
515
520
  testID?: string;
516
521
  }) {
517
522
  const compact = layout === 'compact';
523
+ const scrollY = useSharedValue(0);
524
+ const onScroll = useAnimatedScrollHandler((event) => {
525
+ scrollY.value = Math.max(0, event.contentOffset.y);
526
+ }, [scrollY]);
518
527
  return (
519
- <ScrollView
528
+ <Animated.ScrollView
520
529
  role="navigation"
521
530
  aria-label={label}
522
531
  style={[
523
532
  compact
524
533
  ? styles.railCompact
525
534
  : [styles.rail, { width: layout === 'medium' ? RAIL_WIDTH_MEDIUM : RAIL_WIDTH }],
526
- { backgroundColor: palette.secondary, borderRightColor: palette.separator },
535
+ {
536
+ backgroundColor: palette.secondary,
537
+ borderRightColor: palette.separator,
538
+ },
527
539
  ]}
528
- contentContainerStyle={[styles.railContent, compact ? styles.railContentCompact : null]}
540
+ contentContainerStyle={header ? undefined : styles.railContent}
541
+ stickyHeaderIndices={header && !IS_WEB ? [0] : undefined}
542
+ onScroll={onScroll}
543
+ scrollEventThrottle={16}
544
+ contentInsetAdjustmentBehavior="never"
545
+ automaticallyAdjustContentInsets={false}
529
546
  showsVerticalScrollIndicator={false}
530
547
  testID={testID ? `${testID}-rail` : undefined}
531
548
  >
532
- {groups.map((group) => (
533
- <View key={group.key ?? group.label} style={styles.group}>
534
- <Text variant="body-medium" style={[styles.groupLabel, { color: palette.textSecondary }]}>
535
- {group.label}
536
- </Text>
537
- <View style={styles.groupRows}>
538
- {group.items.map((item) => (
539
- <RailRow
540
- key={item.key}
541
- item={item}
542
- // A list, not a selection, when the rows ARE the navigation.
543
- selected={!compact && item.page !== undefined && item.page === page}
544
- showChevron={compact && item.page !== undefined}
545
- onSelect={onSelect}
546
- palette={palette}
547
- testID={testID ? `${testID}-nav-${item.key}` : undefined}
548
- />
549
- ))}
549
+ {header ? (
550
+ <SettingsHeader
551
+ {...header}
552
+ palette={palette}
553
+ scrimColor={palette.secondary}
554
+ scrollY={scrollY}
555
+ testID={testID}
556
+ />
557
+ ) : null}
558
+ <View style={header ? [styles.railContent, styles.railContentCompact] : { gap: 20 }}>
559
+ {groups.map((group) => (
560
+ <View key={group.key ?? group.label} style={styles.group}>
561
+ <Text
562
+ variant="body-medium"
563
+ style={[styles.groupLabel, { color: palette.textSecondary }]}
564
+ >
565
+ {group.label}
566
+ </Text>
567
+ <View style={styles.groupRows}>
568
+ {group.items.map((item) => (
569
+ <RailRow
570
+ key={item.key}
571
+ item={item}
572
+ // A list, not a selection, when the rows ARE the navigation.
573
+ selected={!compact && item.page !== undefined && item.page === page}
574
+ showChevron={compact && item.page !== undefined}
575
+ onSelect={onSelect}
576
+ palette={palette}
577
+ testID={testID ? `${testID}-nav-${item.key}` : undefined}
578
+ />
579
+ ))}
580
+ </View>
550
581
  </View>
551
- </View>
552
- ))}
553
- </ScrollView>
582
+ ))}
583
+ </View>
584
+ </Animated.ScrollView>
554
585
  );
555
586
  }
556
587
 
@@ -571,9 +602,7 @@ function RailRow({
571
602
  }) {
572
603
  const [hovered, setHovered] = useState(false);
573
604
  const Icon = item.icon;
574
- const onPress = item.page
575
- ? () => onSelect(item.page as string)
576
- : item.onPress;
605
+ const onPress = item.page ? () => onSelect(item.page as string) : item.onPress;
577
606
  return (
578
607
  <Pressable
579
608
  role="button"
@@ -604,7 +633,9 @@ function RailRow({
604
633
  numberOfLines={1}
605
634
  style={[
606
635
  styles.railLabel,
607
- { color: selected || showChevron ? palette.text : palette.textSecondary },
636
+ {
637
+ color: selected || showChevron ? palette.text : palette.textSecondary,
638
+ },
608
639
  ]}
609
640
  >
610
641
  {item.label}
@@ -623,6 +654,14 @@ function RailRow({
623
654
  // ---------------------------------------------------------------------------
624
655
 
625
656
  /** Shared header on every layout; the modal already owns safe-area insets. */
657
+ type SettingsHeaderProps = {
658
+ title: string;
659
+ onBack?: () => void;
660
+ backLabel?: string;
661
+ closeLabel: string;
662
+ onClose: () => void;
663
+ };
664
+
626
665
  function SettingsHeader({
627
666
  title,
628
667
  onBack,
@@ -630,19 +669,15 @@ function SettingsHeader({
630
669
  closeLabel,
631
670
  onClose,
632
671
  palette,
672
+ scrollY,
673
+ scrimColor = palette.full,
633
674
  testID,
634
- }: {
635
- title: string;
636
- onBack?: () => void;
637
- backLabel?: string;
638
- closeLabel: string;
639
- onClose: () => void;
675
+ }: SettingsHeaderProps & {
640
676
  palette: SettingsPalette;
677
+ scrollY: SharedValue<number>;
678
+ scrimColor?: string;
641
679
  testID?: string;
642
680
  }) {
643
- // Inline chrome does not reveal with scroll. An explicit local offset keeps
644
- // the page behind the modal from influencing this header's paint or title.
645
- const scrollY = useSharedValue(0);
646
681
  return (
647
682
  <PageHeader
648
683
  title={title}
@@ -650,10 +685,10 @@ function SettingsHeader({
650
685
  onBack={onBack}
651
686
  backLabel={backLabel}
652
687
  safeArea={false}
653
- sticky={false}
688
+ sticky={IS_WEB}
654
689
  placement="inline"
655
- scrim="none"
656
- scrimColor={palette.full}
690
+ scrim="auto"
691
+ scrimColor={scrimColor}
657
692
  scrollY={scrollY}
658
693
  actions={
659
694
  <ButtonGroup accessibilityLabel={closeLabel}>
@@ -671,54 +706,49 @@ function SettingsHeader({
671
706
  );
672
707
  }
673
708
 
709
+ /** Each page owns one full-height viewport, including its header. */
674
710
  function PageScroller({
675
711
  children,
712
+ header,
676
713
  palette,
677
714
  inset,
678
715
  insetTop = 0,
679
716
  testID,
680
717
  }: {
681
718
  children: React.ReactNode;
719
+ header: SettingsHeaderProps;
682
720
  palette: SettingsPalette;
683
721
  inset: number;
684
- /** Content gap below PageHeader; desktop Storage uses the compact 6px gap. */
722
+ /** Body spacing below the header; compact-title pages keep their smaller gap. */
685
723
  insetTop?: number;
686
724
  testID?: string;
687
725
  }) {
688
- const [scrolled, setScrolled] = useState(false);
689
- const onScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
690
- const next = event.nativeEvent.contentOffset.y > 0;
691
- setScrolled((prev) => (prev === next ? prev : next));
692
- }, []);
693
- const fade: WebCssStyle = {
694
- opacity: scrolled ? 1 : 0,
695
- ...(IS_WEB
696
- ? { transitionProperty: 'opacity', transitionDuration: '200ms', transitionTimingFunction: 'ease-out' }
697
- : null),
698
- };
726
+ const scrollY = useSharedValue(0);
727
+ const onScroll = useAnimatedScrollHandler((event) => {
728
+ scrollY.value = Math.max(0, event.contentOffset.y);
729
+ }, [scrollY]);
699
730
  return (
700
- <View style={styles.scrollHost}>
701
- <ScrollView
702
- style={styles.scroll}
703
- contentContainerStyle={[
704
- styles.scrollContent,
705
- { paddingLeft: inset, paddingRight: inset, paddingBottom: inset, paddingTop: insetTop },
706
- ]}
707
- onScroll={onScroll}
708
- scrollEventThrottle={16}
709
- testID={testID ? `${testID}-page` : undefined}
710
- >
711
- {children}
712
- </ScrollView>
713
- {/* Progressive top fade — `from-background-primary-default`. */}
731
+ <Animated.ScrollView
732
+ style={styles.scroll}
733
+ stickyHeaderIndices={IS_WEB ? undefined : [0]}
734
+ onScroll={onScroll}
735
+ scrollEventThrottle={16}
736
+ contentInsetAdjustmentBehavior="never"
737
+ automaticallyAdjustContentInsets={false}
738
+ testID={testID ? `${testID}-page` : undefined}
739
+ >
740
+ <SettingsHeader {...header} palette={palette} scrollY={scrollY} testID={testID} />
714
741
  <View
715
- pointerEvents="none"
716
- style={[styles.fade, fade]}
717
- testID={testID ? `${testID}-fade` : undefined}
742
+ style={{
743
+ paddingLeft: inset,
744
+ paddingRight: inset,
745
+ paddingBottom: inset,
746
+ paddingTop: insetTop,
747
+ }}
718
748
  >
719
- <VerticalFade color={palette.primary} />
749
+ {children}
720
750
  </View>
721
- </View>
751
+ </Animated.ScrollView>
722
752
  );
723
753
  }
724
754
 
@@ -848,21 +878,9 @@ const styles = StyleSheet.create({
848
878
  flex: 1,
849
879
  minWidth: 0,
850
880
  },
851
- scrollHost: {
852
- flex: 1,
853
- minHeight: 0,
854
- position: 'relative',
855
- },
856
881
  scroll: {
857
882
  flex: 1,
858
- },
859
- scrollContent: {},
860
- fade: {
861
- position: 'absolute',
862
- left: 0,
863
- right: 0,
864
- top: 0,
865
- height: 40,
883
+ minHeight: 0,
866
884
  },
867
885
  toast: {
868
886
  position: 'absolute',
@@ -66,6 +66,9 @@ export interface SettingsModalProps {
66
66
  * Imperative mode: the modal finished closing.
67
67
  */
68
68
  onClose?: () => void;
69
+ /** Veto an internal leave intention before it changes or unmounts content.
70
+ * External controlled open/page changes remain the caller's authority. */
71
+ onBeforeLeave?: (reason: 'close' | 'navigation' | 'page') => boolean;
69
72
  groups: SettingsNavGroup[];
70
73
  pages: Record<string, SettingsModalPage>;
71
74
  /** Controlled current page. */
@@ -141,6 +141,8 @@ function SearchField({
141
141
  const SidebarPanel: React.FC<SidebarProps> = ({
142
142
  items = [],
143
143
  primaryAction,
144
+ content,
145
+ onScroll,
144
146
  footer,
145
147
  secondaryItems = [],
146
148
  modes,
@@ -210,7 +212,7 @@ const SidebarPanel: React.FC<SidebarProps> = ({
210
212
 
211
213
  // `fluid` fills its container while expanded; the morph needs a number, so
212
214
  // the last expanded width is measured. The collapsed end is the size's own.
213
- const geometry = useMemo(() => resolveSidebarGeometry(metrics, surface, Boolean(primaryAction), Boolean(tree)), [metrics, surface, primaryAction, tree]);
215
+ const geometry = useMemo(() => resolveSidebarGeometry(metrics, surface, Boolean(primaryAction), Boolean(tree) || content != null), [metrics, surface, primaryAction, tree, content]);
214
216
  const { collapsedWidth, collapsedLane, expandedInset } = geometry;
215
217
  const expandedWidth = useSharedValue(metrics.expanded);
216
218
  useEffect(() => {
@@ -232,6 +234,13 @@ const SidebarPanel: React.FC<SidebarProps> = ({
232
234
  paddingLeft: padding, paddingRight: padding,
233
235
  };
234
236
  }, [progress, expandedWidth, fluid, collapsedWidth, expandedPadding, collapsedPadding]);
237
+ const [contentHeight, setContentHeight] = useState(0);
238
+ const contentMorph = useAnimatedStyle(() => ({
239
+ height: contentHeight > 0 ? contentHeight * (1 - progress.value) : collapsed ? 0 : undefined,
240
+ opacity: 1 - progress.value,
241
+ marginTop: tree ? 0 : 24 * (1 - progress.value),
242
+ }), [contentHeight, collapsed, progress, tree]);
243
+ const contentWidth = useAnimatedStyle(() => ({ width: expandedWidth.value - expandedPadding * 2 }), [expandedWidth, expandedPadding]);
235
244
  const navInset = useAnimatedStyle(() => {
236
245
  const inset = expandedInset * (1 - progress.value);
237
246
  return { paddingLeft: inset, paddingRight: inset };
@@ -534,8 +543,8 @@ const SidebarPanel: React.FC<SidebarProps> = ({
534
543
  [tree, normalized],
535
544
  );
536
545
  const nothingMatches =
537
- shownItems.length === 0 && shownSecondary.length === 0 && shownFolders.length === 0 && !collapsed;
538
- const hasTree = !!tree;
546
+ shownItems.length === 0 && shownSecondary.length === 0 && shownFolders.length === 0 && content == null && !collapsed;
547
+ const hasTree = !!tree || content != null;
539
548
 
540
549
  // A fresh closure here would re-render the memoised switcher on every
541
550
  // keystroke in the quick-search field.
@@ -642,8 +651,9 @@ const SidebarPanel: React.FC<SidebarProps> = ({
642
651
  testID={`${testID ?? 'sidebar'}-scroll`}
643
652
  {...(IS_WEB ? { dataSet: { bloomSidebarScroll: 'none' } } : {})}
644
653
  style={{ marginTop: -headerOverlap, marginBottom: -8, marginLeft: -8, marginRight: -8, flexGrow: 0, flexShrink: 1, minHeight: 0 }}
645
- contentContainerStyle={{ paddingTop: headerOverlap + 12, paddingBottom: 8, paddingLeft: 8, paddingRight: 8, gap: hasTree ? 24 : 0 }}
654
+ contentContainerStyle={{ paddingTop: headerOverlap + 12, paddingBottom: 8, paddingLeft: 8, paddingRight: 8, gap: tree ? 24 : 0 }}
646
655
  showsVerticalScrollIndicator={false}
656
+ onScroll={onScroll}
647
657
  >
648
658
  {shownItems.length > 0 || !hasTree ? (
649
659
  <Animated.View role="navigation" style={[{ width: '100%', gap: 4 }, !hasTree && navInset]}>
@@ -665,6 +675,17 @@ const SidebarPanel: React.FC<SidebarProps> = ({
665
675
  </View>
666
676
  </View>
667
677
  ) : null}
678
+ {content != null ? (
679
+ <Animated.View testID={`${testID ?? 'sidebar'}-content`} pointerEvents={collapsed ? 'none' : 'auto'}
680
+ aria-hidden={collapsed} accessibilityElementsHidden={collapsed}
681
+ importantForAccessibility={collapsed ? 'no-hide-descendants' : 'auto'}
682
+ {...(IS_WEB && collapsed ? { inert: true } : {})}
683
+ style={[{ overflow: 'hidden', flexShrink: 0 }, contentMorph]}>
684
+ <Animated.View onLayout={(event) => setContentHeight(event.nativeEvent.layout.height)} style={contentWidth}>
685
+ {content}
686
+ </Animated.View>
687
+ </Animated.View>
688
+ ) : null}
668
689
  {nothingMatches && hasTree ? (
669
690
  <Text variant="body-regular" style={{ paddingLeft: 8, paddingRight: 8, color: palette.textTertiary }}>{noResultsLabel}</Text>
670
691
  ) : null}
@@ -1,6 +1,6 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import type { BloomIconComponent } from '../icons/icon-component';
3
- import type { StyleProp, ViewStyle } from 'react-native';
3
+ import type { StyleProp, ViewStyle, ScrollViewProps } from 'react-native';
4
4
 
5
5
  /** A Remix-style icon: `width` / `height` / `fill`. */
6
6
  /** @deprecated Use `BloomIconComponent` from `@oxy.so/bloom/icons`; this is an alias of it. */
@@ -232,6 +232,17 @@ export interface SidebarUserMenuProps {
232
232
  }
233
233
 
234
234
  export interface SidebarProps {
235
+ /** App-owned destination content after the built-in rows/tree, inside the same
236
+ * scroll viewport. Hidden and noninteractive while collapsed; remains mounted
237
+ * so pagination and disclosure state survive. Not rendered in variant="rail".
238
+ * Compose rows here, never another ScrollView. Quick search filters only the
239
+ * built-in items/tree; disable it when your content owns search.
240
+ */
241
+ content?: ReactNode;
242
+ /** Observes the destination viewport after Bloom updates its edge fades.
243
+ * Useful for loading another page of history. No separate scroll owner.
244
+ */
245
+ onScroll?: ScrollViewProps['onScroll'];
235
246
  /** Additional persistent bottom content, outside destination scrolling.
236
247
  * Receives the rendered state: true in a rail, false in a mobile panel.
237
248
  * Existing account/theme/team controls remain; omit account when supplying your own account footer.