@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.
- package/docs/adoption-matrix.mdx +8 -8
- package/docs/ai-chat.mdx +20 -1
- package/docs/settings-modal.mdx +7 -1
- package/docs/sidebar.mdx +11 -0
- package/lib/commonjs/ai-chat/AiChatContainer.js +8 -8
- package/lib/commonjs/ai-chat/AiChatContainer.js.map +1 -1
- package/lib/commonjs/ai-chat/AiChatShell.js +13 -8
- package/lib/commonjs/ai-chat/AiChatShell.js.map +1 -1
- package/lib/commonjs/ai-chat/use-panel-interaction.js +75 -0
- package/lib/commonjs/ai-chat/use-panel-interaction.js.map +1 -0
- package/lib/commonjs/overlay/stack.js +6 -0
- package/lib/commonjs/overlay/stack.js.map +1 -1
- package/lib/commonjs/settings-modal/SettingsModal.js +120 -112
- package/lib/commonjs/settings-modal/SettingsModal.js.map +1 -1
- package/lib/commonjs/sidebar/Sidebar.js +34 -4
- package/lib/commonjs/sidebar/Sidebar.js.map +1 -1
- package/lib/module/ai-chat/AiChatContainer.js +8 -8
- package/lib/module/ai-chat/AiChatContainer.js.map +1 -1
- package/lib/module/ai-chat/AiChatShell.js +13 -8
- package/lib/module/ai-chat/AiChatShell.js.map +1 -1
- package/lib/module/ai-chat/use-panel-interaction.js +71 -0
- package/lib/module/ai-chat/use-panel-interaction.js.map +1 -0
- package/lib/module/overlay/stack.js +5 -0
- package/lib/module/overlay/stack.js.map +1 -1
- package/lib/module/settings-modal/SettingsModal.js +123 -115
- package/lib/module/settings-modal/SettingsModal.js.map +1 -1
- package/lib/module/sidebar/Sidebar.js +34 -4
- package/lib/module/sidebar/Sidebar.js.map +1 -1
- package/lib/typescript/commonjs/ai-chat/AiChatShell.d.ts +1 -1
- package/lib/typescript/commonjs/ai-chat/AiChatShell.d.ts.map +1 -1
- package/lib/typescript/commonjs/ai-chat/types.d.ts +4 -4
- package/lib/typescript/commonjs/ai-chat/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/ai-chat/use-panel-interaction.d.ts +4 -0
- package/lib/typescript/commonjs/ai-chat/use-panel-interaction.d.ts.map +1 -0
- package/lib/typescript/commonjs/overlay/stack.d.ts +2 -0
- package/lib/typescript/commonjs/overlay/stack.d.ts.map +1 -1
- package/lib/typescript/commonjs/settings-modal/SettingsModal.d.ts +1 -1
- package/lib/typescript/commonjs/settings-modal/SettingsModal.d.ts.map +1 -1
- package/lib/typescript/commonjs/settings-modal/types.d.ts +3 -0
- package/lib/typescript/commonjs/settings-modal/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/sidebar/Sidebar.d.ts.map +1 -1
- package/lib/typescript/commonjs/sidebar/types.d.ts +12 -1
- package/lib/typescript/commonjs/sidebar/types.d.ts.map +1 -1
- package/lib/typescript/module/ai-chat/AiChatShell.d.ts +1 -1
- package/lib/typescript/module/ai-chat/AiChatShell.d.ts.map +1 -1
- package/lib/typescript/module/ai-chat/types.d.ts +4 -4
- package/lib/typescript/module/ai-chat/types.d.ts.map +1 -1
- package/lib/typescript/module/ai-chat/use-panel-interaction.d.ts +4 -0
- package/lib/typescript/module/ai-chat/use-panel-interaction.d.ts.map +1 -0
- package/lib/typescript/module/overlay/stack.d.ts +2 -0
- package/lib/typescript/module/overlay/stack.d.ts.map +1 -1
- package/lib/typescript/module/settings-modal/SettingsModal.d.ts +1 -1
- package/lib/typescript/module/settings-modal/SettingsModal.d.ts.map +1 -1
- package/lib/typescript/module/settings-modal/types.d.ts +3 -0
- package/lib/typescript/module/settings-modal/types.d.ts.map +1 -1
- package/lib/typescript/module/sidebar/Sidebar.d.ts.map +1 -1
- package/lib/typescript/module/sidebar/types.d.ts +12 -1
- package/lib/typescript/module/sidebar/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/support/adoption-matrix.ts +703 -0
- package/src/__tests__/support/collision-fixture-barrel.ts +20 -0
- package/src/__tests__/support/commerce-harness.tsx +97 -0
- package/src/__tests__/support/composite.ts +68 -0
- package/src/__tests__/support/constructed-style-sheets.ts +68 -0
- package/src/__tests__/support/press-host.ts +30 -0
- package/src/__tests__/support/rendered-style.ts +99 -0
- package/src/__tests__/support/unread-hook-fixture.ts +33 -0
- package/src/ai-chat/AiChatContainer.tsx +7 -7
- package/src/ai-chat/AiChatShell.tsx +11 -5
- package/src/ai-chat/types.ts +4 -4
- package/src/ai-chat/use-panel-interaction.ts +71 -0
- package/src/overlay/stack.ts +5 -0
- package/src/settings-modal/SettingsModal.tsx +150 -132
- package/src/settings-modal/types.ts +3 -0
- package/src/sidebar/Sidebar.tsx +25 -4
- package/src/sidebar/types.ts +12 -1
- package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +9346 -0
- package/src/theme/__tests__/__fixtures__/tonal-glass-primary-failures.json +69 -0
- 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
|
|
63
|
-
*
|
|
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> = {
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
if (cb) setTimeout(cb, UNMOUNT_MS);
|
|
228
|
+
if (requestClose() && cb) setTimeout(cb, UNMOUNT_MS);
|
|
226
229
|
},
|
|
227
230
|
}),
|
|
228
|
-
[show,
|
|
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)
|
|
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
|
-
? {
|
|
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
|
-
{
|
|
535
|
+
{
|
|
536
|
+
backgroundColor: palette.secondary,
|
|
537
|
+
borderRightColor: palette.separator,
|
|
538
|
+
},
|
|
527
539
|
]}
|
|
528
|
-
contentContainerStyle={
|
|
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
|
-
{
|
|
533
|
-
<
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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={
|
|
688
|
+
sticky={IS_WEB}
|
|
654
689
|
placement="inline"
|
|
655
|
-
scrim="
|
|
656
|
-
scrimColor={
|
|
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
|
-
/**
|
|
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
|
|
689
|
-
const onScroll =
|
|
690
|
-
|
|
691
|
-
|
|
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
|
-
<
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
-
|
|
716
|
-
|
|
717
|
-
|
|
742
|
+
style={{
|
|
743
|
+
paddingLeft: inset,
|
|
744
|
+
paddingRight: inset,
|
|
745
|
+
paddingBottom: inset,
|
|
746
|
+
paddingTop: insetTop,
|
|
747
|
+
}}
|
|
718
748
|
>
|
|
719
|
-
|
|
749
|
+
{children}
|
|
720
750
|
</View>
|
|
721
|
-
</
|
|
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. */
|
package/src/sidebar/Sidebar.tsx
CHANGED
|
@@ -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:
|
|
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}
|
package/src/sidebar/types.ts
CHANGED
|
@@ -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.
|