@delacour/react-native-ui 0.1.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -0
- package/package.json +125 -0
- package/src/components/accordion/AGENTS.md +192 -0
- package/src/components/accordion/accordion-content.tsx +125 -0
- package/src/components/accordion/accordion-description.tsx +17 -0
- package/src/components/accordion/accordion-indicator.tsx +104 -0
- package/src/components/accordion/accordion-item.tsx +78 -0
- package/src/components/accordion/accordion-title.tsx +18 -0
- package/src/components/accordion/accordion-trigger.tsx +148 -0
- package/src/components/accordion/accordion.context.tsx +153 -0
- package/src/components/accordion/accordion.tsx +253 -0
- package/src/components/accordion/accordion.types.ts +11 -0
- package/src/components/accordion/accordion.variants.test.ts +435 -0
- package/src/components/accordion/accordion.variants.ts +358 -0
- package/src/components/accordion/index.ts +45 -0
- package/src/components/badge/AGENTS.md +83 -0
- package/src/components/badge/badge-close-button.tsx +48 -0
- package/src/components/badge/badge-end-content.tsx +15 -0
- package/src/components/badge/badge-label.tsx +24 -0
- package/src/components/badge/badge-start-content.tsx +16 -0
- package/src/components/badge/badge.context.tsx +64 -0
- package/src/components/badge/badge.tsx +192 -0
- package/src/components/badge/badge.types.ts +10 -0
- package/src/components/badge/badge.variants.test.ts +269 -0
- package/src/components/badge/badge.variants.ts +238 -0
- package/src/components/badge/index.ts +17 -0
- package/src/components/bottom-sheet/AGENTS.md +232 -0
- package/src/components/bottom-sheet/bottom-sheet-background.tsx +35 -0
- package/src/components/bottom-sheet/bottom-sheet-close.tsx +60 -0
- package/src/components/bottom-sheet/bottom-sheet-container.tsx +245 -0
- package/src/components/bottom-sheet/bottom-sheet-content.tsx +66 -0
- package/src/components/bottom-sheet/bottom-sheet-description.tsx +29 -0
- package/src/components/bottom-sheet/bottom-sheet-footer.tsx +148 -0
- package/src/components/bottom-sheet/bottom-sheet-handle.tsx +47 -0
- package/src/components/bottom-sheet/bottom-sheet-overlay.tsx +180 -0
- package/src/components/bottom-sheet/bottom-sheet-portal.tsx +72 -0
- package/src/components/bottom-sheet/bottom-sheet-scroll-view.tsx +95 -0
- package/src/components/bottom-sheet/bottom-sheet-title.tsx +24 -0
- package/src/components/bottom-sheet/bottom-sheet-trigger.tsx +73 -0
- package/src/components/bottom-sheet/bottom-sheet.context.tsx +135 -0
- package/src/components/bottom-sheet/bottom-sheet.tsx +125 -0
- package/src/components/bottom-sheet/bottom-sheet.variants.test.ts +276 -0
- package/src/components/bottom-sheet/bottom-sheet.variants.ts +199 -0
- package/src/components/bottom-sheet/index.ts +40 -0
- package/src/components/bottom-sheet/use-bottom-sheet-input.ts +125 -0
- package/src/components/button/AGENTS.md +217 -0
- package/src/components/button/button-end-content.tsx +15 -0
- package/src/components/button/button-group-separator.tsx +37 -0
- package/src/components/button/button-group-text.tsx +101 -0
- package/src/components/button/button-group.tsx +158 -0
- package/src/components/button/button-label.tsx +24 -0
- package/src/components/button/button-start-content.tsx +15 -0
- package/src/components/button/button.context.tsx +187 -0
- package/src/components/button/button.tsx +273 -0
- package/src/components/button/button.types.ts +10 -0
- package/src/components/button/button.variants.test.ts +811 -0
- package/src/components/button/button.variants.ts +480 -0
- package/src/components/button/index.ts +50 -0
- package/src/components/chart/AGENTS.md +210 -0
- package/src/components/chart/chart-area.tsx +93 -0
- package/src/components/chart/chart-bar.tsx +103 -0
- package/src/components/chart/chart-bars.tsx +75 -0
- package/src/components/chart/chart-candlestick.tsx +60 -0
- package/src/components/chart/chart-grid.tsx +33 -0
- package/src/components/chart/chart-legend.tsx +41 -0
- package/src/components/chart/chart-line.tsx +50 -0
- package/src/components/chart/chart-scatter.tsx +35 -0
- package/src/components/chart/chart-tooltip-dot.tsx +70 -0
- package/src/components/chart/chart-tooltip-x.tsx +121 -0
- package/src/components/chart/chart-tooltip-y.tsx +55 -0
- package/src/components/chart/chart-tooltip.tsx +133 -0
- package/src/components/chart/chart-x-axis.tsx +27 -0
- package/src/components/chart/chart-y-axis.tsx +21 -0
- package/src/components/chart/chart.context.tsx +127 -0
- package/src/components/chart/chart.tsx +448 -0
- package/src/components/chart/chart.types.ts +108 -0
- package/src/components/chart/chart.variants.test.ts +893 -0
- package/src/components/chart/chart.variants.ts +726 -0
- package/src/components/chart/index.ts +103 -0
- package/src/components/chart/pie-chart-center.tsx +40 -0
- package/src/components/chart/pie-chart-label.tsx +56 -0
- package/src/components/chart/pie-chart-slice.tsx +45 -0
- package/src/components/chart/pie-chart-tooltip.tsx +85 -0
- package/src/components/chart/pie-chart.context.tsx +77 -0
- package/src/components/chart/pie-chart.tsx +247 -0
- package/src/components/chart/use-chart-font.ts +25 -0
- package/src/components/chart/use-chart-palette.ts +38 -0
- package/src/components/checkbox/AGENTS.md +185 -0
- package/src/components/checkbox/checkbox-box.tsx +152 -0
- package/src/components/checkbox/checkbox-group.tsx +107 -0
- package/src/components/checkbox/checkbox-label.tsx +34 -0
- package/src/components/checkbox/checkbox.context.tsx +136 -0
- package/src/components/checkbox/checkbox.tsx +258 -0
- package/src/components/checkbox/checkbox.types.ts +14 -0
- package/src/components/checkbox/checkbox.variants.test.ts +634 -0
- package/src/components/checkbox/checkbox.variants.ts +484 -0
- package/src/components/checkbox/index.ts +42 -0
- package/src/components/field/AGENTS.md +100 -0
- package/src/components/field/field-content.tsx +24 -0
- package/src/components/field/field-description.tsx +29 -0
- package/src/components/field/field-error.tsx +61 -0
- package/src/components/field/field-group.tsx +22 -0
- package/src/components/field/field-label.tsx +33 -0
- package/src/components/field/field-legend.tsx +34 -0
- package/src/components/field/field-separator.tsx +81 -0
- package/src/components/field/field-set.tsx +22 -0
- package/src/components/field/field.context.tsx +78 -0
- package/src/components/field/field.tsx +144 -0
- package/src/components/field/field.types.ts +15 -0
- package/src/components/field/field.variants.test.ts +216 -0
- package/src/components/field/field.variants.ts +136 -0
- package/src/components/field/index.ts +21 -0
- package/src/components/icon/AGENTS.md +104 -0
- package/src/components/icon/icon.context.tsx +29 -0
- package/src/components/icon/icon.tsx +92 -0
- package/src/components/icon/icon.variants.test.ts +111 -0
- package/src/components/icon/icon.variants.ts +79 -0
- package/src/components/icon/index.ts +11 -0
- package/src/components/input/AGENTS.md +126 -0
- package/src/components/input/index.ts +27 -0
- package/src/components/input/input-group-decorator.tsx +84 -0
- package/src/components/input/input-group-prefix.tsx +16 -0
- package/src/components/input/input-group-suffix.tsx +15 -0
- package/src/components/input/input-group.tsx +140 -0
- package/src/components/input/input.context.tsx +87 -0
- package/src/components/input/input.tsx +184 -0
- package/src/components/input/input.types.ts +10 -0
- package/src/components/input/input.variants.test.ts +369 -0
- package/src/components/input/input.variants.ts +329 -0
- package/src/components/list-group/AGENTS.md +67 -0
- package/src/components/list-group/index.ts +20 -0
- package/src/components/list-group/list-group-item-content.tsx +10 -0
- package/src/components/list-group/list-group-item-description.tsx +12 -0
- package/src/components/list-group/list-group-item-prefix.tsx +30 -0
- package/src/components/list-group/list-group-item-suffix.tsx +55 -0
- package/src/components/list-group/list-group-item-title.tsx +17 -0
- package/src/components/list-group/list-group-item.tsx +87 -0
- package/src/components/list-group/list-group.context.tsx +65 -0
- package/src/components/list-group/list-group.tsx +116 -0
- package/src/components/list-group/list-group.types.ts +14 -0
- package/src/components/list-group/list-group.variants.test.ts +223 -0
- package/src/components/list-group/list-group.variants.ts +107 -0
- package/src/components/pressable/AGENTS.md +64 -0
- package/src/components/pressable/index.ts +9 -0
- package/src/components/pressable/pressable.tsx +256 -0
- package/src/components/pressable/pressable.variants.test.ts +99 -0
- package/src/components/pressable/pressable.variants.ts +63 -0
- package/src/components/provider/AGENTS.md +90 -0
- package/src/components/provider/index.ts +1 -0
- package/src/components/provider/provider.tsx +105 -0
- package/src/components/radio/AGENTS.md +247 -0
- package/src/components/radio/index.ts +35 -0
- package/src/components/radio/radio-group.tsx +126 -0
- package/src/components/radio/radio-indicator.tsx +91 -0
- package/src/components/radio/radio-label.tsx +39 -0
- package/src/components/radio/radio.context.tsx +132 -0
- package/src/components/radio/radio.tsx +215 -0
- package/src/components/radio/radio.variants.test.ts +581 -0
- package/src/components/radio/radio.variants.ts +272 -0
- package/src/components/screen/AGENTS.md +287 -0
- package/src/components/screen/index.ts +85 -0
- package/src/components/screen/screen-chat-list.tsx +485 -0
- package/src/components/screen/screen-content.tsx +69 -0
- package/src/components/screen/screen-debug.ts +43 -0
- package/src/components/screen/screen-error.tsx +61 -0
- package/src/components/screen/screen-flat-list.tsx +88 -0
- package/src/components/screen/screen-footer-background.tsx +60 -0
- package/src/components/screen/screen-footer.tsx +170 -0
- package/src/components/screen/screen-header.tsx +36 -0
- package/src/components/screen/screen-legend-list.tsx +105 -0
- package/src/components/screen/screen-list-component.tsx +23 -0
- package/src/components/screen/screen-loading.tsx +50 -0
- package/src/components/screen/screen-navbar-back-button.tsx +79 -0
- package/src/components/screen/screen-navbar-background.tsx +45 -0
- package/src/components/screen/screen-navbar-subtitle.tsx +26 -0
- package/src/components/screen/screen-navbar-title.tsx +31 -0
- package/src/components/screen/screen-navbar.tsx +154 -0
- package/src/components/screen/screen-root.tsx +38 -0
- package/src/components/screen/screen-scroll-area.tsx +116 -0
- package/src/components/screen/screen-scroll-shadow.tsx +186 -0
- package/src/components/screen/screen-section-list.tsx +81 -0
- package/src/components/screen/screen-view.tsx +59 -0
- package/src/components/screen/screen.context.tsx +167 -0
- package/src/components/screen/screen.tsx +114 -0
- package/src/components/screen/screen.types.ts +66 -0
- package/src/components/screen/screen.variants.test.ts +602 -0
- package/src/components/screen/screen.variants.ts +475 -0
- package/src/components/screen/use-screen-scroll-insets.ts +218 -0
- package/src/components/separator/AGENTS.md +30 -0
- package/src/components/separator/index.ts +8 -0
- package/src/components/separator/separator.tsx +85 -0
- package/src/components/slider/AGENTS.md +274 -0
- package/src/components/slider/index.ts +48 -0
- package/src/components/slider/slider-fill.tsx +68 -0
- package/src/components/slider/slider-output.tsx +55 -0
- package/src/components/slider/slider-thumb.tsx +161 -0
- package/src/components/slider/slider-track.tsx +233 -0
- package/src/components/slider/slider.context.tsx +118 -0
- package/src/components/slider/slider.tsx +321 -0
- package/src/components/slider/slider.types.ts +26 -0
- package/src/components/slider/slider.variants.test.ts +804 -0
- package/src/components/slider/slider.variants.ts +607 -0
- package/src/components/spinner/AGENTS.md +73 -0
- package/src/components/spinner/index.ts +17 -0
- package/src/components/spinner/spinner-arc.tsx +90 -0
- package/src/components/spinner/spinner-content.tsx +63 -0
- package/src/components/spinner/spinner.context.tsx +52 -0
- package/src/components/spinner/spinner.tsx +128 -0
- package/src/components/spinner/spinner.variants.test.ts +273 -0
- package/src/components/spinner/spinner.variants.ts +187 -0
- package/src/components/switch/AGENTS.md +213 -0
- package/src/components/switch/index.ts +42 -0
- package/src/components/switch/switch-content.tsx +111 -0
- package/src/components/switch/switch-end-content.tsx +18 -0
- package/src/components/switch/switch-start-content.tsx +20 -0
- package/src/components/switch/switch-thumb.tsx +102 -0
- package/src/components/switch/switch.context.tsx +79 -0
- package/src/components/switch/switch.tsx +423 -0
- package/src/components/switch/switch.types.ts +14 -0
- package/src/components/switch/switch.variants.test.ts +570 -0
- package/src/components/switch/switch.variants.ts +511 -0
- package/src/components/tabs/AGENTS.md +286 -0
- package/src/components/tabs/index.ts +69 -0
- package/src/components/tabs/tabs-content.tsx +65 -0
- package/src/components/tabs/tabs-indicator.tsx +97 -0
- package/src/components/tabs/tabs-label.tsx +62 -0
- package/src/components/tabs/tabs-list.tsx +139 -0
- package/src/components/tabs/tabs-pager.tsx +59 -0
- package/src/components/tabs/tabs-scroll-view.tsx +171 -0
- package/src/components/tabs/tabs-separator.tsx +73 -0
- package/src/components/tabs/tabs-trigger.tsx +210 -0
- package/src/components/tabs/tabs.context.tsx +294 -0
- package/src/components/tabs/tabs.tsx +435 -0
- package/src/components/tabs/tabs.types.ts +13 -0
- package/src/components/tabs/tabs.variants.test.ts +1020 -0
- package/src/components/tabs/tabs.variants.ts +672 -0
- package/src/components/text/AGENTS.md +95 -0
- package/src/components/text/index.ts +25 -0
- package/src/components/text/text.context.tsx +60 -0
- package/src/components/text/text.tsx +251 -0
- package/src/components/text/text.variants.test.ts +422 -0
- package/src/components/text/text.variants.ts +282 -0
- package/src/display-name.test.ts +145 -0
- package/src/docs.test.ts +98 -0
- package/src/expo/navigation-theme.tsx +58 -0
- package/src/hooks/use-controllable-state.ts +45 -0
- package/src/hooks/use-keyboard-state-sync.tsx +147 -0
- package/src/hooks/use-navigation-theme.ts +78 -0
- package/src/hooks/use-theme-color.ts +44 -0
- package/src/icons/central.ts +1 -0
- package/src/lib/cn.test.ts +136 -0
- package/src/lib/cn.ts +29 -0
- package/src/lib/color.test.ts +80 -0
- package/src/lib/color.ts +79 -0
- package/src/lib/compose-refs.test.ts +64 -0
- package/src/lib/compose-refs.ts +37 -0
- package/src/lib/keyboard-animation.test.ts +33 -0
- package/src/lib/keyboard-animation.ts +28 -0
- package/src/lib/merge-props.test.ts +82 -0
- package/src/lib/merge-props.ts +47 -0
- package/src/lib/navigation-theme.test.ts +49 -0
- package/src/lib/navigation-theme.ts +53 -0
- package/src/lib/slot.tsx +45 -0
- package/src/lib/tv.ts +20 -0
- package/src/styles/base.css +2 -0
- package/src/styles/geometry.test.ts +117 -0
- package/src/styles/index.css +3 -0
- package/src/styles/theme-tokens.test.ts +252 -0
- package/src/styles/theme.css +447 -0
- package/src/styles/tokens.css +132 -0
- package/src/styles/tokens.test.ts +181 -0
- package/src/styles/tokens.ts +86 -0
- package/src/uniwind-env.d.ts +1 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { type ForwardedRef, forwardRef, type ReactElement, type RefAttributes, useMemo } from "react";
|
|
2
|
+
import { FlatList, type FlatListProps, View } from "react-native";
|
|
3
|
+
import Animated from "react-native-reanimated";
|
|
4
|
+
import { cn } from "../../lib/cn";
|
|
5
|
+
import type { ScreenScrollableProps } from "./screen.types";
|
|
6
|
+
import { screenVariants } from "./screen.variants";
|
|
7
|
+
import { resolveListComponent } from "./screen-list-component";
|
|
8
|
+
import { useScreenScrollInsets } from "./use-screen-scroll-insets";
|
|
9
|
+
|
|
10
|
+
// `Animated.createAnimatedComponent` erases the generic parameters; the recast
|
|
11
|
+
// puts them back so `data` and `renderItem` still check against each other.
|
|
12
|
+
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList) as unknown as typeof FlatList;
|
|
13
|
+
|
|
14
|
+
export type ScreenFlatListProps<ItemT> = Omit<FlatListProps<ItemT>, "children"> & ScreenScrollableProps;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The component's own type, restated so `<ItemT>` survives `forwardRef`.
|
|
18
|
+
*
|
|
19
|
+
* `forwardRef` erases type parameters — its result is a
|
|
20
|
+
* `ForwardRefExoticComponent` over one concrete instantiation — so `data` and
|
|
21
|
+
* `renderItem` would check against `unknown` and stop constraining each other.
|
|
22
|
+
* Writing the signature out is the same move `StyledAnimatedLegendListComponent`
|
|
23
|
+
* makes for `withUniwind` in `screen-legend-list`, and the `displayName` member
|
|
24
|
+
* is what keeps the trailing assignment legal after the cast.
|
|
25
|
+
*/
|
|
26
|
+
type ScreenFlatListComponent = (<ItemT>(
|
|
27
|
+
props: ScreenFlatListProps<ItemT> & RefAttributes<FlatList<ItemT>>
|
|
28
|
+
) => ReactElement) & { displayName?: string };
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A virtualised list that keeps its content clear of the screen's chrome.
|
|
32
|
+
*
|
|
33
|
+
* The reserves ride on `ListHeaderComponent` / `ListFooterComponent` rather
|
|
34
|
+
* than on the content container's padding, because a virtualised list measures
|
|
35
|
+
* its own content and a padded container would put the spacer outside what it
|
|
36
|
+
* measures.
|
|
37
|
+
*
|
|
38
|
+
* A caller's own header and footer components still render — they are composed
|
|
39
|
+
* inside the screen's, not replaced by them.
|
|
40
|
+
*
|
|
41
|
+
* The ref is the list itself, for `scrollToIndex`, `scrollToOffset` and the rest.
|
|
42
|
+
*/
|
|
43
|
+
export const ScreenFlatList = forwardRef(function ScreenFlatListRender<ItemT>(
|
|
44
|
+
{
|
|
45
|
+
header,
|
|
46
|
+
contentContainerClassName,
|
|
47
|
+
ListHeaderComponent: ListHeaderComponentProp,
|
|
48
|
+
ListFooterComponent: ListFooterComponentProp,
|
|
49
|
+
...props
|
|
50
|
+
}: ScreenFlatListProps<ItemT>,
|
|
51
|
+
ref: ForwardedRef<FlatList<ItemT>>
|
|
52
|
+
): ReactElement {
|
|
53
|
+
const { scrollHandler, insetTopAnimatedStyle, insetBottomAnimatedStyle } = useScreenScrollInsets("standard");
|
|
54
|
+
|
|
55
|
+
const ListHeaderComponent = useMemo<ReactElement>(
|
|
56
|
+
() => (
|
|
57
|
+
<>
|
|
58
|
+
<Animated.View style={insetTopAnimatedStyle} />
|
|
59
|
+
{header}
|
|
60
|
+
{resolveListComponent(ListHeaderComponentProp)}
|
|
61
|
+
</>
|
|
62
|
+
),
|
|
63
|
+
[header, ListHeaderComponentProp, insetTopAnimatedStyle]
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const ListFooterComponent = useMemo<ReactElement>(
|
|
67
|
+
() => (
|
|
68
|
+
<View>
|
|
69
|
+
{resolveListComponent(ListFooterComponentProp)}
|
|
70
|
+
<Animated.View style={insetBottomAnimatedStyle} />
|
|
71
|
+
</View>
|
|
72
|
+
),
|
|
73
|
+
[ListFooterComponentProp, insetBottomAnimatedStyle]
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<AnimatedFlatList
|
|
78
|
+
showsVerticalScrollIndicator={false}
|
|
79
|
+
{...props}
|
|
80
|
+
contentContainerClassName={cn(screenVariants().scrollContent(), contentContainerClassName)}
|
|
81
|
+
ListFooterComponent={ListFooterComponent}
|
|
82
|
+
ListHeaderComponent={ListHeaderComponent}
|
|
83
|
+
onScroll={scrollHandler}
|
|
84
|
+
ref={ref}
|
|
85
|
+
/>
|
|
86
|
+
);
|
|
87
|
+
}) as unknown as ScreenFlatListComponent;
|
|
88
|
+
ScreenFlatList.displayName = "DelacourUI.Screen.FlatList";
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { memo, type ReactElement } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
|
4
|
+
import { useScreenDebug, useScreenPart } from "./screen.context";
|
|
5
|
+
import { resolveFooterBorderOpacity, type ScreenPlacement, screenVariants } from "./screen.variants";
|
|
6
|
+
import { SCREEN_DEBUG_COLORS } from "./screen-debug";
|
|
7
|
+
|
|
8
|
+
export type ScreenFooterBackgroundProps = {
|
|
9
|
+
placement: ScreenPlacement;
|
|
10
|
+
/** Fade the top hairline out as the content runs out, instead of drawing it at rest. */
|
|
11
|
+
fadeOnScroll?: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The footer's backing and its top hairline.
|
|
16
|
+
*
|
|
17
|
+
* Both are opaque only when the footer is `static`. A static footer is chrome:
|
|
18
|
+
* it takes its own space, and the content above must not show through when the
|
|
19
|
+
* keyboard lifts it. An `overlay` footer floats over content that deliberately
|
|
20
|
+
* scrolls under it, so it gets neither.
|
|
21
|
+
*
|
|
22
|
+
* Rendered INSIDE `KeyboardStickyView` so it travels with the translation. On
|
|
23
|
+
* the positioned outer view it would stay behind while the buttons moved.
|
|
24
|
+
*
|
|
25
|
+
* Separated from the footer itself because it reads `scrollY`, which changes
|
|
26
|
+
* every frame: memoised and isolated, the animated style re-runs on the UI
|
|
27
|
+
* thread without the footer's children re-rendering with it — the same reason
|
|
28
|
+
* `ScreenNavbarBackground` is its own component.
|
|
29
|
+
*/
|
|
30
|
+
export const ScreenFooterBackground = memo(function ScreenFooterBackground({
|
|
31
|
+
placement,
|
|
32
|
+
fadeOnScroll = false,
|
|
33
|
+
}: ScreenFooterBackgroundProps): ReactElement {
|
|
34
|
+
const { scrollY, contentHeight, layoutHeight } = useScreenPart("Screen.Footer");
|
|
35
|
+
const debug = useScreenDebug();
|
|
36
|
+
const slots = screenVariants({ placement });
|
|
37
|
+
|
|
38
|
+
const borderStyle = useAnimatedStyle(
|
|
39
|
+
() => ({
|
|
40
|
+
opacity: resolveFooterBorderOpacity({
|
|
41
|
+
contentHeight: contentHeight.value,
|
|
42
|
+
fadeOnScroll,
|
|
43
|
+
layoutHeight: layoutHeight.value,
|
|
44
|
+
scrollY: scrollY.value,
|
|
45
|
+
}),
|
|
46
|
+
}),
|
|
47
|
+
[fadeOnScroll]
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<View
|
|
52
|
+
className={slots.footerBackground()}
|
|
53
|
+
pointerEvents="none"
|
|
54
|
+
style={debug ? { backgroundColor: SCREEN_DEBUG_COLORS.footerSticky } : undefined}
|
|
55
|
+
>
|
|
56
|
+
<Animated.View className={slots.footerBorder()} style={borderStyle} />
|
|
57
|
+
</View>
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
ScreenFooterBackground.displayName = "DelacourUI.Screen.Footer.Background";
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { type LayoutChangeEvent, View } from "react-native";
|
|
3
|
+
import { KeyboardStickyView, useKeyboardState } from "react-native-keyboard-controller";
|
|
4
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
5
|
+
import { useKeyboardAnimationGuard } from "../../hooks/use-keyboard-state-sync";
|
|
6
|
+
import { useScreenDebug, useScreenPart } from "./screen.context";
|
|
7
|
+
import type { ScreenPlacementProps } from "./screen.types";
|
|
8
|
+
import { SCREEN_FLOATING_BOTTOM_GAP, SCREEN_FOOTER_PADDING, screenVariants } from "./screen.variants";
|
|
9
|
+
import { SCREEN_DEBUG_COLORS } from "./screen-debug";
|
|
10
|
+
import { ScreenFooterBackground } from "./screen-footer-background";
|
|
11
|
+
|
|
12
|
+
export type ScreenFooterProps = ScreenPlacementProps & {
|
|
13
|
+
/**
|
|
14
|
+
* Ride the keyboard, so the footer stays directly above it. Off by default —
|
|
15
|
+
* a footer holding a submit button wants to stay put, while one holding a
|
|
16
|
+
* composer does not.
|
|
17
|
+
*/
|
|
18
|
+
sticky?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Fade the top hairline out as the content runs out, instead of drawing it at
|
|
21
|
+
* rest.
|
|
22
|
+
*
|
|
23
|
+
* The mirror of `Screen.Navbar`'s `fadeBorderOnScroll`, and off by default
|
|
24
|
+
* for the same reason: the line separates chrome from content, and content
|
|
25
|
+
* sitting right against the footer wants it from the first frame. Turn it on
|
|
26
|
+
* for a footer that should read as joined to the content once there is
|
|
27
|
+
* nothing left to scroll to.
|
|
28
|
+
*
|
|
29
|
+
* Only applies to a `static` footer — an overlay one draws no hairline.
|
|
30
|
+
*
|
|
31
|
+
* @default false
|
|
32
|
+
*/
|
|
33
|
+
fadeBorderOnScroll?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Whether the enclosing screen is the focused one. Defaults to `true`.
|
|
36
|
+
*
|
|
37
|
+
* A sticky footer on a backgrounded screen would otherwise follow a keyboard
|
|
38
|
+
* opened on the screen in front of it. This library takes no navigation
|
|
39
|
+
* dependency, so an app that has one passes its own signal — expo-router and
|
|
40
|
+
* React Navigation both expose `useIsFocused()`.
|
|
41
|
+
*/
|
|
42
|
+
isFocused?: boolean;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A pinned region at the bottom of a screen, optionally riding the keyboard.
|
|
47
|
+
*
|
|
48
|
+
* Measures its CONTENT height — excluding its own padding and the safe-area
|
|
49
|
+
* band — into the screen context, so a scrollable can reserve exactly what the
|
|
50
|
+
* footer covers. `footerOccupancy` in `screen.variants` adds the rest back; the
|
|
51
|
+
* two must agree, which is why the padding is applied from the same constants
|
|
52
|
+
* rather than as a class.
|
|
53
|
+
*
|
|
54
|
+
* The VERTICAL padding is a style rather than a class for that reason alone. A
|
|
55
|
+
* class is unreadable from JS, so the reserve would have to restate the number
|
|
56
|
+
* and the two could drift — the failure being a list whose last row hides under the
|
|
57
|
+
* footer only on some devices.
|
|
58
|
+
*
|
|
59
|
+
* Four nested boxes, each with one job:
|
|
60
|
+
* 1. the positioned outer view, which the caller's `className` reaches;
|
|
61
|
+
* 2. `KeyboardStickyView`, which translates and carries the footer's padding;
|
|
62
|
+
* 3. the backing and its top hairline, filling the sticky view behind the rest;
|
|
63
|
+
* 4. the measured content box, whose height reaches the screen context.
|
|
64
|
+
*
|
|
65
|
+
* The backing lives INSIDE the sticky view, not on the positioned outer one, so
|
|
66
|
+
* it travels with the translation. A static footer lifted over the content by
|
|
67
|
+
* the keyboard would otherwise let that content show straight through it — the
|
|
68
|
+
* buttons legible only where they happened to overlap blank space.
|
|
69
|
+
*
|
|
70
|
+
* The sticky view sits *inside* the positioned view rather than being it, so
|
|
71
|
+
* this file needs no `withUniwind` wrapper — it takes a `style` and never a
|
|
72
|
+
* `className`. See AGENTS.md rule 6.
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* <Screen.Footer>
|
|
76
|
+
* <Button onPress={save}>Save</Button>
|
|
77
|
+
* </Screen.Footer>
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* <Screen.Footer sticky>
|
|
81
|
+
* <MessageComposer />
|
|
82
|
+
* </Screen.Footer>
|
|
83
|
+
*/
|
|
84
|
+
export function ScreenFooter({
|
|
85
|
+
placement = "overlay",
|
|
86
|
+
sticky = false,
|
|
87
|
+
fadeBorderOnScroll = false,
|
|
88
|
+
isFocused = true,
|
|
89
|
+
className,
|
|
90
|
+
children,
|
|
91
|
+
style,
|
|
92
|
+
...props
|
|
93
|
+
}: ScreenFooterProps): ReactElement {
|
|
94
|
+
const { footer } = useScreenPart("Screen.Footer");
|
|
95
|
+
const slots = screenVariants({ placement });
|
|
96
|
+
const { bottom } = useSafeAreaInsets();
|
|
97
|
+
const keyboardState = useKeyboardState();
|
|
98
|
+
const debug = useScreenDebug();
|
|
99
|
+
// The shared keyboard values are a global with no per-screen reset, so a
|
|
100
|
+
// footer can mount into a state left "open" by a keyboard that vanished
|
|
101
|
+
// without a `keyboardWillHide` — and then float a keyboard-height off the
|
|
102
|
+
// bottom of a screen with no keyboard on it. Repair on mount; a no-op
|
|
103
|
+
// whenever an input is genuinely focused, which is what keeps the
|
|
104
|
+
// `isKeyboardOpen` hand-off below working.
|
|
105
|
+
useKeyboardAnimationGuard();
|
|
106
|
+
|
|
107
|
+
// Sticking before the first measure would translate a zero-height box.
|
|
108
|
+
const [hasMeasured, setHasMeasured] = useState(false);
|
|
109
|
+
// A JS ref, never a SharedValue read in a memoized closure: React Compiler
|
|
110
|
+
// hoists such loads into memo-cache comparisons that run during render,
|
|
111
|
+
// which is a Reanimated strict-mode violation.
|
|
112
|
+
const hasCapturedBaselineRef = useRef(false);
|
|
113
|
+
|
|
114
|
+
// A screen navigated away from while its keyboard is still up has to keep
|
|
115
|
+
// reacting, or its footer snaps down behind the outgoing transition.
|
|
116
|
+
const isEnabled = sticky && hasMeasured && (isFocused || keyboardState.isVisible);
|
|
117
|
+
|
|
118
|
+
const handleContentLayout = useCallback(
|
|
119
|
+
(event: LayoutChangeEvent) => {
|
|
120
|
+
const { height } = event.nativeEvent.layout;
|
|
121
|
+
setHasMeasured(true);
|
|
122
|
+
footer.height.value = height;
|
|
123
|
+
if (!hasCapturedBaselineRef.current) {
|
|
124
|
+
hasCapturedBaselineRef.current = true;
|
|
125
|
+
footer.initialHeight.value = height;
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
[footer]
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
footer.placement.value = placement;
|
|
133
|
+
}, [placement, footer.placement]);
|
|
134
|
+
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
return () => {
|
|
137
|
+
footer.height.value = 0;
|
|
138
|
+
footer.initialHeight.value = 0;
|
|
139
|
+
hasCapturedBaselineRef.current = false;
|
|
140
|
+
};
|
|
141
|
+
}, [footer.height, footer.initialHeight]);
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<View className={slots.footer({ className })} style={style} {...props}>
|
|
145
|
+
<KeyboardStickyView
|
|
146
|
+
enabled={isEnabled}
|
|
147
|
+
// Shift by the inset rather than animating the padding away: the
|
|
148
|
+
// footer's safe-area band is meaningless once the keyboard covers it,
|
|
149
|
+
// and animating padding leaves a visible gap between the two
|
|
150
|
+
// mid-transition.
|
|
151
|
+
offset={{ closed: 0, opened: bottom }}
|
|
152
|
+
style={{ paddingBottom: SCREEN_FLOATING_BOTTOM_GAP, paddingTop: SCREEN_FOOTER_PADDING }}
|
|
153
|
+
>
|
|
154
|
+
<ScreenFooterBackground fadeOnScroll={fadeBorderOnScroll} placement={placement} />
|
|
155
|
+
<View
|
|
156
|
+
style={[{ paddingBottom: bottom }, debug ? { backgroundColor: SCREEN_DEBUG_COLORS.footerSafeArea } : null]}
|
|
157
|
+
>
|
|
158
|
+
<View
|
|
159
|
+
className={slots.footerContent()}
|
|
160
|
+
onLayout={handleContentLayout}
|
|
161
|
+
style={debug ? { backgroundColor: SCREEN_DEBUG_COLORS.footerContent } : undefined}
|
|
162
|
+
>
|
|
163
|
+
{children}
|
|
164
|
+
</View>
|
|
165
|
+
</View>
|
|
166
|
+
</KeyboardStickyView>
|
|
167
|
+
</View>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
ScreenFooter.displayName = "DelacourUI.Screen.Footer";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
4
|
+
import type { ScreenInsetProps } from "./screen.types";
|
|
5
|
+
import { resolveScreenEdgePadding, screenVariants } from "./screen.variants";
|
|
6
|
+
|
|
7
|
+
export type ScreenHeaderProps = ScreenInsetProps;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A titled block at the top of a screen's content.
|
|
11
|
+
*
|
|
12
|
+
* Carries no gutter and no vertical padding: the scrollable it sits in already
|
|
13
|
+
* has both, and a header adding its own would double them at every call site.
|
|
14
|
+
* What is left is a semantic block with its own `gap-1` and optional safe-area
|
|
15
|
+
* insets.
|
|
16
|
+
*
|
|
17
|
+
* Two nested views rather than one: the safe-area padding and anything the
|
|
18
|
+
* caller adds are different concerns, and collapsing them would make
|
|
19
|
+
* `insets={["top"]}` silently replace that padding instead of adding to it —
|
|
20
|
+
* the failure the rest of this component was fixed for.
|
|
21
|
+
*
|
|
22
|
+
* Scrolls with the content — it is passed to a scrollable's `header` prop, not
|
|
23
|
+
* pinned. `Screen.Navbar` is the pinned one.
|
|
24
|
+
*/
|
|
25
|
+
export function ScreenHeader({ insets, className, children, style, ...props }: ScreenHeaderProps): ReactElement {
|
|
26
|
+
const safeArea = useSafeAreaInsets();
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<View style={resolveScreenEdgePadding(insets, safeArea)}>
|
|
30
|
+
<View className={screenVariants().header({ className })} style={style} {...props}>
|
|
31
|
+
{children}
|
|
32
|
+
</View>
|
|
33
|
+
</View>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
ScreenHeader.displayName = "DelacourUI.Screen.Header";
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { LegendListRef } from "@legendapp/list/react-native";
|
|
2
|
+
import { AnimatedLegendList, type AnimatedLegendListProps } from "@legendapp/list/reanimated";
|
|
3
|
+
import { type ForwardedRef, forwardRef, type ReactElement, type Ref, type RefAttributes, useMemo } from "react";
|
|
4
|
+
import { View } from "react-native";
|
|
5
|
+
import Animated from "react-native-reanimated";
|
|
6
|
+
import { withUniwind } from "uniwind";
|
|
7
|
+
import { cn } from "../../lib/cn";
|
|
8
|
+
import type { ScreenScrollableProps } from "./screen.types";
|
|
9
|
+
import { screenVariants } from "./screen.variants";
|
|
10
|
+
import { resolveListComponent } from "./screen-list-component";
|
|
11
|
+
import { useScreenScrollInsets } from "./use-screen-scroll-insets";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The class props `withUniwind` adds, restated so the generic survives.
|
|
15
|
+
*
|
|
16
|
+
* `withUniwind`'s own return type maps over a component's props and collapses
|
|
17
|
+
* `<ItemT>` in the process, which would leave `data` and `renderItem` checking
|
|
18
|
+
* against `unknown`. Writing the signature out keeps inference and still
|
|
19
|
+
* declares exactly the two props the wrapper adds.
|
|
20
|
+
*/
|
|
21
|
+
type StyledAnimatedLegendListComponent = <ItemT>(
|
|
22
|
+
props: AnimatedLegendListProps<ItemT> & {
|
|
23
|
+
ref?: Ref<LegendListRef>;
|
|
24
|
+
className?: string;
|
|
25
|
+
contentContainerClassName?: string;
|
|
26
|
+
}
|
|
27
|
+
) => ReactElement | null;
|
|
28
|
+
|
|
29
|
+
// Third-party, so `className` needs the wrapper — and it has to be built at
|
|
30
|
+
// module scope or every render mints a new component type and remounts the list.
|
|
31
|
+
const StyledAnimatedLegendList = withUniwind(AnimatedLegendList) as unknown as StyledAnimatedLegendListComponent;
|
|
32
|
+
|
|
33
|
+
export type ScreenLegendListProps<ItemT> = Omit<AnimatedLegendListProps<ItemT>, "renderScrollComponent"> &
|
|
34
|
+
ScreenScrollableProps;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The component's own type, restated so `<ItemT>` survives `forwardRef`.
|
|
38
|
+
*
|
|
39
|
+
* Same reasoning as `StyledAnimatedLegendListComponent` above, one layer out:
|
|
40
|
+
* `forwardRef` erases type parameters, so `data` and `renderItem` would check
|
|
41
|
+
* against `unknown`.
|
|
42
|
+
*/
|
|
43
|
+
type ScreenLegendListComponent = (<ItemT>(
|
|
44
|
+
props: ScreenLegendListProps<ItemT> & RefAttributes<LegendListRef>
|
|
45
|
+
) => ReactElement) & { displayName?: string };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A recycling list that keeps its content clear of the screen's chrome.
|
|
49
|
+
*
|
|
50
|
+
* `Screen.FlatList`'s behaviour on LegendList's recycling engine, for a long or
|
|
51
|
+
* heterogeneous list. Same navbar, footer and keyboard reserves — they come
|
|
52
|
+
* from the shared `useScreenScrollInsets`, so the three lists cannot drift.
|
|
53
|
+
*
|
|
54
|
+
* For a chat, reach for `Screen.ChatList` instead: it adds the composer
|
|
55
|
+
* clearance and the keyboard lift that a conversation needs and this one has no
|
|
56
|
+
* opinion about.
|
|
57
|
+
*
|
|
58
|
+
* The ref is a `LegendListRef`.
|
|
59
|
+
*/
|
|
60
|
+
export const ScreenLegendList = forwardRef(function ScreenLegendListRender<ItemT>(
|
|
61
|
+
{
|
|
62
|
+
header,
|
|
63
|
+
contentContainerClassName,
|
|
64
|
+
ListHeaderComponent: ListHeaderComponentProp,
|
|
65
|
+
ListFooterComponent: ListFooterComponentProp,
|
|
66
|
+
...props
|
|
67
|
+
}: ScreenLegendListProps<ItemT>,
|
|
68
|
+
ref: ForwardedRef<LegendListRef>
|
|
69
|
+
): ReactElement {
|
|
70
|
+
const { scrollHandler, insetTopAnimatedStyle, insetBottomAnimatedStyle } = useScreenScrollInsets("standard");
|
|
71
|
+
|
|
72
|
+
const ListHeaderComponent = useMemo<ReactElement>(
|
|
73
|
+
() => (
|
|
74
|
+
<>
|
|
75
|
+
<Animated.View style={insetTopAnimatedStyle} />
|
|
76
|
+
{header}
|
|
77
|
+
{resolveListComponent(ListHeaderComponentProp)}
|
|
78
|
+
</>
|
|
79
|
+
),
|
|
80
|
+
[header, ListHeaderComponentProp, insetTopAnimatedStyle]
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const ListFooterComponent = useMemo<ReactElement>(
|
|
84
|
+
() => (
|
|
85
|
+
<View>
|
|
86
|
+
{resolveListComponent(ListFooterComponentProp)}
|
|
87
|
+
<Animated.View style={insetBottomAnimatedStyle} />
|
|
88
|
+
</View>
|
|
89
|
+
),
|
|
90
|
+
[ListFooterComponentProp, insetBottomAnimatedStyle]
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<StyledAnimatedLegendList
|
|
95
|
+
showsVerticalScrollIndicator={false}
|
|
96
|
+
{...(props as AnimatedLegendListProps<ItemT>)}
|
|
97
|
+
contentContainerClassName={cn(screenVariants().scrollContent(), contentContainerClassName)}
|
|
98
|
+
ListFooterComponent={ListFooterComponent}
|
|
99
|
+
ListHeaderComponent={ListHeaderComponent}
|
|
100
|
+
onScroll={scrollHandler}
|
|
101
|
+
ref={ref}
|
|
102
|
+
/>
|
|
103
|
+
);
|
|
104
|
+
}) as unknown as ScreenLegendListComponent;
|
|
105
|
+
ScreenLegendList.displayName = "DelacourUI.Screen.LegendList";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ComponentType, isValidElement, type ReactElement, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/** What React Native's lists accept for `ListHeaderComponent` / `ListFooterComponent`. */
|
|
4
|
+
export type ListComponent = ComponentType<unknown> | ReactElement | null | undefined;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Renders a list's header or footer prop as a node, whichever form it took.
|
|
8
|
+
*
|
|
9
|
+
* React Native accepts a component type *or* an element for these props, and a
|
|
10
|
+
* Screen scrollable has to compose the caller's into its own spacers — which
|
|
11
|
+
* only works with a node. Calling a component type as a function would break
|
|
12
|
+
* its hooks, so it is rendered as an element instead.
|
|
13
|
+
*
|
|
14
|
+
* Shared by every Screen list, so it lives in a leaf of its own rather than in
|
|
15
|
+
* whichever list happened to need it first.
|
|
16
|
+
*/
|
|
17
|
+
export function resolveListComponent(component: ListComponent): ReactNode {
|
|
18
|
+
if (component == null) return null;
|
|
19
|
+
if (isValidElement(component)) return component;
|
|
20
|
+
|
|
21
|
+
const Component = component as ComponentType<unknown>;
|
|
22
|
+
return <Component />;
|
|
23
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { Spinner } from "../spinner";
|
|
4
|
+
import { screenVariants } from "./screen.variants";
|
|
5
|
+
import { ScreenContent } from "./screen-content";
|
|
6
|
+
import { ScreenNavbar } from "./screen-navbar";
|
|
7
|
+
import { ScreenRoot } from "./screen-root";
|
|
8
|
+
|
|
9
|
+
export type ScreenLoadingProps = {
|
|
10
|
+
/** Title for the navbar, if the screen shows one. */
|
|
11
|
+
title?: string;
|
|
12
|
+
/** Back handler. Omit it and the navbar renders without a back control. */
|
|
13
|
+
onBack?: () => void;
|
|
14
|
+
/** Show the navbar at all. On by default, so the frame does not shift when the content arrives. */
|
|
15
|
+
showNavbar?: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A whole screen showing a spinner — the placeholder a route renders while its
|
|
20
|
+
* data is in flight.
|
|
21
|
+
*
|
|
22
|
+
* Keeps the navbar by default so the frame does not jump when the real content
|
|
23
|
+
* replaces it, and so the user can still go back out of a slow load.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* if (isPending) return <Screen.Loading onBack={() => router.back()} title="Settings" />;
|
|
27
|
+
*/
|
|
28
|
+
export function ScreenLoading({ title, onBack, showNavbar = true }: ScreenLoadingProps): ReactElement {
|
|
29
|
+
return (
|
|
30
|
+
<ScreenRoot>
|
|
31
|
+
{showNavbar ? (
|
|
32
|
+
<ScreenNavbar>
|
|
33
|
+
{onBack ? (
|
|
34
|
+
<ScreenNavbar.BackButton onPress={onBack}>
|
|
35
|
+
{title ? <ScreenNavbar.Title>{title}</ScreenNavbar.Title> : null}
|
|
36
|
+
</ScreenNavbar.BackButton>
|
|
37
|
+
) : title ? (
|
|
38
|
+
<ScreenNavbar.Title>{title}</ScreenNavbar.Title>
|
|
39
|
+
) : null}
|
|
40
|
+
</ScreenNavbar>
|
|
41
|
+
) : null}
|
|
42
|
+
<ScreenContent>
|
|
43
|
+
<View className={screenVariants().loading()}>
|
|
44
|
+
<Spinner size="xl" />
|
|
45
|
+
</View>
|
|
46
|
+
</ScreenContent>
|
|
47
|
+
</ScreenRoot>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
ScreenLoading.displayName = "DelacourUI.Screen.Loading";
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { IconChevronLeft, IconCrossSmall } from "../../icons/central";
|
|
3
|
+
import { Icon, IconDefaultsProvider } from "../icon";
|
|
4
|
+
import { Pressable, type PressableProps } from "../pressable";
|
|
5
|
+
import { screenVariants } from "./screen.variants";
|
|
6
|
+
|
|
7
|
+
/** Which affordance the control reads as: going back, or closing a modal. */
|
|
8
|
+
export const SCREEN_BACK_BUTTON_GLYPHS = ["chevron", "close"] as const;
|
|
9
|
+
|
|
10
|
+
export type ScreenBackButtonGlyph = (typeof SCREEN_BACK_BUTTON_GLYPHS)[number];
|
|
11
|
+
|
|
12
|
+
export type ScreenNavbarBackButtonProps = Omit<PressableProps, "onPress"> & {
|
|
13
|
+
/** `chevron` for a push, `close` for a modal. */
|
|
14
|
+
glyph?: ScreenBackButtonGlyph;
|
|
15
|
+
/**
|
|
16
|
+
* What going back does. Required: this library takes no navigation
|
|
17
|
+
* dependency, so the screen wires its own router — `() => router.back()`.
|
|
18
|
+
*/
|
|
19
|
+
onPress: () => void;
|
|
20
|
+
/**
|
|
21
|
+
* Runs before `onPress`. Returning `false` cancels the navigation, for an
|
|
22
|
+
* unsaved-changes prompt.
|
|
23
|
+
*/
|
|
24
|
+
onBeforeBack?: () => boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const GLYPH: Record<ScreenBackButtonGlyph, typeof IconChevronLeft> = {
|
|
28
|
+
chevron: IconChevronLeft,
|
|
29
|
+
close: IconCrossSmall,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The navbar's leading control.
|
|
34
|
+
*
|
|
35
|
+
* Takes an `onPress` rather than calling a router itself. `native-ui` has no
|
|
36
|
+
* navigation dependency and should not gain one for a chevron — the screen
|
|
37
|
+
* already knows how it was pushed, and an app on a different router still gets
|
|
38
|
+
* to use this.
|
|
39
|
+
*
|
|
40
|
+
* Children render beside the glyph, so a title can share the control's tap
|
|
41
|
+
* target — which is what makes the whole "‹ Settings" row pressable rather than
|
|
42
|
+
* just the chevron.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* <Screen.Navbar.BackButton onPress={() => router.back()}>
|
|
46
|
+
* <Screen.Navbar.Title>Settings</Screen.Navbar.Title>
|
|
47
|
+
* </Screen.Navbar.BackButton>
|
|
48
|
+
*/
|
|
49
|
+
export function ScreenNavbarBackButton({
|
|
50
|
+
glyph = "chevron",
|
|
51
|
+
onPress,
|
|
52
|
+
onBeforeBack,
|
|
53
|
+
className,
|
|
54
|
+
children,
|
|
55
|
+
accessibilityLabel = "Back",
|
|
56
|
+
...props
|
|
57
|
+
}: ScreenNavbarBackButtonProps): ReactElement {
|
|
58
|
+
function handlePress(): void {
|
|
59
|
+
if (onBeforeBack && !onBeforeBack()) return;
|
|
60
|
+
onPress();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<Pressable
|
|
65
|
+
accessibilityLabel={accessibilityLabel}
|
|
66
|
+
accessibilityRole="button"
|
|
67
|
+
className={screenVariants().backButton({ className })}
|
|
68
|
+
feedback="fade"
|
|
69
|
+
onPress={handlePress}
|
|
70
|
+
{...props}
|
|
71
|
+
>
|
|
72
|
+
<IconDefaultsProvider value={{ className: "size-icon-lg", color: "foreground" }}>
|
|
73
|
+
<Icon icon={GLYPH[glyph]} />
|
|
74
|
+
{children}
|
|
75
|
+
</IconDefaultsProvider>
|
|
76
|
+
</Pressable>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
ScreenNavbarBackButton.displayName = "DelacourUI.Screen.Navbar.BackButton";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { memo, type ReactElement } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
|
4
|
+
import { useScreenPart } from "./screen.context";
|
|
5
|
+
import { resolveNavbarBorderOpacity, screenVariants } from "./screen.variants";
|
|
6
|
+
|
|
7
|
+
export type ScreenNavbarBackgroundProps = {
|
|
8
|
+
className?: string;
|
|
9
|
+
/** Ramp the hairline in as the screen scrolls instead of drawing it at rest. */
|
|
10
|
+
fadeOnScroll?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The navbar's opaque backing, and its bottom hairline.
|
|
15
|
+
*
|
|
16
|
+
* Separated from the navbar itself because it reads `scrollY`, which changes
|
|
17
|
+
* every frame: memoised and isolated, the animated style re-runs on the UI
|
|
18
|
+
* thread without the navbar's children re-rendering with it.
|
|
19
|
+
*
|
|
20
|
+
* The line is drawn at rest by default. `fadeOnScroll` ramps it in over the
|
|
21
|
+
* first {@link SCREEN_BORDER_FADE_DISTANCE} points instead, for a header
|
|
22
|
+
* that should read as undivided until the content moves.
|
|
23
|
+
*
|
|
24
|
+
* The fill is a CLASS rather than an inline style. An inline `backgroundColor`
|
|
25
|
+
* beats the style uniwind resolves from a className, which is what would make a
|
|
26
|
+
* caller's `backgroundClassName` silently inert.
|
|
27
|
+
*/
|
|
28
|
+
export const ScreenNavbarBackground = memo(function ScreenNavbarBackground({
|
|
29
|
+
className,
|
|
30
|
+
fadeOnScroll = false,
|
|
31
|
+
}: ScreenNavbarBackgroundProps): ReactElement {
|
|
32
|
+
const { scrollY } = useScreenPart("Screen.Navbar");
|
|
33
|
+
|
|
34
|
+
const borderStyle = useAnimatedStyle(
|
|
35
|
+
() => ({ opacity: resolveNavbarBorderOpacity(scrollY.value, fadeOnScroll) }),
|
|
36
|
+
[fadeOnScroll]
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<View className={screenVariants().navbarBackground({ className })} pointerEvents="none">
|
|
41
|
+
<Animated.View className={screenVariants().navbarBorder()} style={borderStyle} />
|
|
42
|
+
</View>
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
ScreenNavbarBackground.displayName = "DelacourUI.Screen.Navbar.Background";
|