@delacour/react-native-ui 0.1.0-alpha.20260925053522
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 +193 -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 +434 -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 +242 -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 +246 -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 +150 -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 +105 -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 +293 -0
- package/src/components/bottom-sheet/bottom-sheet.variants.ts +220 -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 +112 -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 +47 -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 +95 -0
- package/src/components/field/field.tsx +157 -0
- package/src/components/field/field.types.ts +15 -0
- package/src/components/field/field.variants.test.ts +240 -0
- package/src/components/field/field.variants.ts +156 -0
- package/src/components/field/index.ts +22 -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 +74 -0
- package/src/components/pressable/index.ts +9 -0
- package/src/components/pressable/pressable.tsx +261 -0
- package/src/components/pressable/pressable.variants.test.ts +128 -0
- package/src/components/pressable/pressable.variants.ts +80 -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 +245 -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 +580 -0
- package/src/components/radio/radio.variants.ts +271 -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 +50 -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 +193 -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 +856 -0
- package/src/components/slider/slider.variants.ts +661 -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 +287 -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 +670 -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,475 @@
|
|
|
1
|
+
import type { VariantProps } from "tailwind-variants";
|
|
2
|
+
import { tv } from "../../lib/tv";
|
|
3
|
+
|
|
4
|
+
/** Where a navbar or footer sits: over the content, or in the flow above and below it. */
|
|
5
|
+
export const SCREEN_PLACEMENTS = ["overlay", "static"] as const;
|
|
6
|
+
|
|
7
|
+
export type ScreenPlacement = (typeof SCREEN_PLACEMENTS)[number];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Safe-area edges a container can inset against.
|
|
11
|
+
*
|
|
12
|
+
* Structurally `Edge` from react-native-safe-area-context, restated here so
|
|
13
|
+
* this module keeps its promise of importing nothing — the whole reason it is
|
|
14
|
+
* reachable from `bun test`.
|
|
15
|
+
*/
|
|
16
|
+
export const SCREEN_EDGES = ["top", "right", "bottom", "left"] as const;
|
|
17
|
+
|
|
18
|
+
export type ScreenEdge = (typeof SCREEN_EDGES)[number];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Styling for every part of a screen.
|
|
22
|
+
*
|
|
23
|
+
* One slotted `tv()` rather than a call per part, so `placement` — the axis the
|
|
24
|
+
* navbar and the footer both turn on — is declared once. The two read the same
|
|
25
|
+
* variant and get different classes from it, which is what keeps "overlay" from
|
|
26
|
+
* meaning `top-0` in one file and `bottom-0` in another.
|
|
27
|
+
*
|
|
28
|
+
* Colour lives on the text slots (`navbarTitle`, `navbarSubtitle`,
|
|
29
|
+
* `errorTitle`, `errorMessage`) and never on a container. A React Native `View`
|
|
30
|
+
* does not cascade colour to a `Text` descendant the way a DOM element does, so
|
|
31
|
+
* a `text-*` on `navbarRow` would simply do nothing — the tests assert it stays
|
|
32
|
+
* off.
|
|
33
|
+
*
|
|
34
|
+
* Safe-area padding is deliberately absent here. Uniwind's `pt-safe` and
|
|
35
|
+
* friends compile to `env(safe-area-inset-*)`, which resolves to zero on React
|
|
36
|
+
* Native — see {@link resolveScreenEdgePadding}.
|
|
37
|
+
*
|
|
38
|
+
* `navbarBackground` clips: the hairline it holds is positioned against the
|
|
39
|
+
* navbar's own box, and the safe-area band has to be painted too or the status
|
|
40
|
+
* bar shows content scrolling underneath it.
|
|
41
|
+
*
|
|
42
|
+
* Free of React Native imports so it stays unit-testable — `bun test` cannot
|
|
43
|
+
* parse React Native's Flow-typed source. See AGENTS.md.
|
|
44
|
+
*/
|
|
45
|
+
export const screenVariants = tv({
|
|
46
|
+
slots: {
|
|
47
|
+
root: "flex-1 bg-background",
|
|
48
|
+
content: "flex-1 bg-background",
|
|
49
|
+
view: "flex-1",
|
|
50
|
+
/**
|
|
51
|
+
* The default padding inside a scrollable, on the token every other edge of
|
|
52
|
+
* the screen lines up with.
|
|
53
|
+
*
|
|
54
|
+
* The library owns it so a screen does not repeat it — twelve call sites
|
|
55
|
+
* writing `p-5`, `px-5`, `py-5` and `px-5 pt-4` by hand is how they drifted
|
|
56
|
+
* apart. A caller overrides per axis (`px-0` for full-bleed rows) and
|
|
57
|
+
* tailwind-merge resolves it, because `screen-gutter` is registered in
|
|
58
|
+
* `styles/tokens.ts`.
|
|
59
|
+
*
|
|
60
|
+
* On a virtualised list this is not a double count. Content-container
|
|
61
|
+
* padding wraps `ListHeaderComponent`, so the order down the screen is
|
|
62
|
+
* padding, then the navbar spacer, then the rows — and the spacer sits
|
|
63
|
+
* behind the overlaying navbar, leaving exactly one gutter of visible
|
|
64
|
+
* breathing room, the same as a scroll area.
|
|
65
|
+
*/
|
|
66
|
+
scrollContent: "p-screen-gutter",
|
|
67
|
+
/**
|
|
68
|
+
* A titled block inside a scrollable, which already carries the gutter and
|
|
69
|
+
* the vertical rhythm — so the header adds neither, or every screen would
|
|
70
|
+
* need `px-0` to undo the second one.
|
|
71
|
+
*
|
|
72
|
+
* `gap-1` is its OWN rhythm, between a title and whatever sits under it,
|
|
73
|
+
* and it is deliberately not the empty string: `tv()` returns `undefined`
|
|
74
|
+
* for a slot with no classes at all, which reads as a bug at the call site
|
|
75
|
+
* and breaks any test that asserts on it.
|
|
76
|
+
*/
|
|
77
|
+
header: "gap-1",
|
|
78
|
+
/**
|
|
79
|
+
* Above the content it overlays. The navbar is the FIRST child of a
|
|
80
|
+
* screen, so without a raised z-index a later sibling paints over it —
|
|
81
|
+
* the footer already wins by document order, which is what a footer
|
|
82
|
+
* should do.
|
|
83
|
+
*/
|
|
84
|
+
navbar: "z-50",
|
|
85
|
+
navbarRow: "h-navbar-row flex-row items-center justify-between gap-2 px-screen-gutter",
|
|
86
|
+
navbarStart: "min-w-0 flex-1 flex-row items-center gap-2",
|
|
87
|
+
navbarActions: "flex-row items-center gap-2",
|
|
88
|
+
navbarCenter: "absolute top-0 right-0 bottom-0 left-0 flex-row items-center justify-center gap-2",
|
|
89
|
+
navbarBackground: "absolute top-0 right-0 bottom-0 left-0 overflow-hidden bg-background",
|
|
90
|
+
navbarBorder: "absolute right-0 bottom-0 left-0 h-px bg-border",
|
|
91
|
+
/**
|
|
92
|
+
* No text alignment of its own. In the `center` slot the flex row already
|
|
93
|
+
* centres it, so `text-center` was redundant there — and in the leading slot
|
|
94
|
+
* it actively fought a title stacked above a subtitle, centring one line in a
|
|
95
|
+
* column that should read left-aligned.
|
|
96
|
+
*/
|
|
97
|
+
navbarTitle: "text-foreground text-lg font-semibold leading-tight",
|
|
98
|
+
navbarSubtitle: "text-muted-foreground text-sm",
|
|
99
|
+
backButton: "flex-row items-center gap-2",
|
|
100
|
+
footer: "",
|
|
101
|
+
/**
|
|
102
|
+
* The footer's own backing, filling the box that travels with the keyboard.
|
|
103
|
+
*
|
|
104
|
+
* Opaque only when the footer is `static`. A static footer is chrome: it
|
|
105
|
+
* takes its own space in the flow, and the content above it must not show
|
|
106
|
+
* through when the keyboard lifts it over that content. An `overlay` footer
|
|
107
|
+
* is the opposite — a surface floating above content that deliberately
|
|
108
|
+
* scrolls under it — so it stays transparent and whatever the caller puts
|
|
109
|
+
* inside brings its own background.
|
|
110
|
+
*/
|
|
111
|
+
footerBackground: "absolute top-0 right-0 bottom-0 left-0",
|
|
112
|
+
/**
|
|
113
|
+
* The hairline along the footer's TOP edge — the mirror of the navbar's,
|
|
114
|
+
* which runs along its bottom. Drawn only for a `static` footer, since an
|
|
115
|
+
* overlay one floats and a rule across the screen above it would read as a
|
|
116
|
+
* divider belonging to the content.
|
|
117
|
+
*/
|
|
118
|
+
footerBorder: "absolute top-0 right-0 left-0 h-px",
|
|
119
|
+
/**
|
|
120
|
+
* The footer's measured content box, and the owner of its HORIZONTAL
|
|
121
|
+
* padding.
|
|
122
|
+
*
|
|
123
|
+
* Horizontal lives here rather than in the sticky view's inline style so it
|
|
124
|
+
* can use the same gutter token as the content above — a footer button used
|
|
125
|
+
* to sit at 16 while the content sat at 20, on every screen with a footer.
|
|
126
|
+
* The vertical stays inline, because `footerOccupancy` has to add the same
|
|
127
|
+
* numbers to a height measured at runtime and a class is unreadable from
|
|
128
|
+
* JS. Only the axis that was wrong moved, so the occupancy maths is
|
|
129
|
+
* untouched.
|
|
130
|
+
*/
|
|
131
|
+
footerContent: "gap-2 px-screen-gutter",
|
|
132
|
+
loading: "flex-1 items-center justify-center",
|
|
133
|
+
errorContent: "flex-1 items-center justify-center gap-3 px-screen-gutter",
|
|
134
|
+
errorTitle: "text-center font-semibold text-foreground text-lg",
|
|
135
|
+
errorMessage: "text-center text-muted-foreground text-sm",
|
|
136
|
+
},
|
|
137
|
+
variants: {
|
|
138
|
+
placement: {
|
|
139
|
+
overlay: {
|
|
140
|
+
navbar: "absolute top-0 right-0 left-0",
|
|
141
|
+
footer: "absolute right-0 bottom-0 left-0",
|
|
142
|
+
},
|
|
143
|
+
static: {
|
|
144
|
+
navbar: "relative",
|
|
145
|
+
footer: "relative",
|
|
146
|
+
footerBackground: "bg-background",
|
|
147
|
+
footerBorder: "bg-border",
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
defaultVariants: {
|
|
152
|
+
placement: "overlay",
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Scroll distance over which a hairline fades, in points.
|
|
158
|
+
*
|
|
159
|
+
* One constant for both edges: the navbar's line and the footer's are the same
|
|
160
|
+
* idea at opposite ends of the content, and two numbers that should always
|
|
161
|
+
* agree are two numbers that can drift.
|
|
162
|
+
*
|
|
163
|
+
* Short on purpose. The line answers a yes/no question — is there content that
|
|
164
|
+
* way? — and a longer ramp reads as the border being slow rather than as a
|
|
165
|
+
* response.
|
|
166
|
+
*/
|
|
167
|
+
export const SCREEN_BORDER_FADE_DISTANCE = 20;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Opacity of the navbar's bottom hairline.
|
|
171
|
+
*
|
|
172
|
+
* Drawn at full strength at rest unless `fadeOnScroll` is on, which is what
|
|
173
|
+
* makes an undivided header opt-in rather than the default — a screen whose
|
|
174
|
+
* content starts flush against the bar wants the line from the first frame, and
|
|
175
|
+
* used to re-add one by hand precisely because it faded.
|
|
176
|
+
*
|
|
177
|
+
* With `fadeOnScroll`, ramps 0 → 1 over the first
|
|
178
|
+
* {@link SCREEN_BORDER_FADE_DISTANCE} points and clamps at both ends —
|
|
179
|
+
* a rubber-banded overscroll reports a negative offset, which would otherwise
|
|
180
|
+
* drive the opacity below zero.
|
|
181
|
+
*
|
|
182
|
+
* Pure, so the whole ramp is reachable from `bun test`. Written out rather than
|
|
183
|
+
* calling Reanimated's `interpolate` so this module keeps its promise of
|
|
184
|
+
* importing nothing. See AGENTS.md.
|
|
185
|
+
*/
|
|
186
|
+
export function resolveNavbarBorderOpacity(scrollY: number, fadeOnScroll: boolean): number {
|
|
187
|
+
"worklet";
|
|
188
|
+
if (!fadeOnScroll) return 1;
|
|
189
|
+
// The clamp is written out in both resolvers rather than shared. A module
|
|
190
|
+
// helper called from a worklet is not always captured into the UI runtime's
|
|
191
|
+
// closure, and the failure is a runtime "undefined is not a function" on the
|
|
192
|
+
// UI thread that no unit test sees — the JS thread resolves it fine.
|
|
193
|
+
return Math.min(1, Math.max(0, scrollY / SCREEN_BORDER_FADE_DISTANCE));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Opacity of the footer's top hairline.
|
|
198
|
+
*
|
|
199
|
+
* The mirror of {@link resolveNavbarBorderOpacity}, and deliberately not the
|
|
200
|
+
* same input. The navbar's line answers "is there content ABOVE?", so it reads
|
|
201
|
+
* the near end of the scroll; the footer's answers "is there content BELOW?",
|
|
202
|
+
* so it reads the far end and fades OUT as the content runs out. A footer line
|
|
203
|
+
* driven by `scrollY` would be brightest exactly where there is nothing left to
|
|
204
|
+
* scroll to.
|
|
205
|
+
*
|
|
206
|
+
* `contentHeight` and `layoutHeight` are published by the scrollable's own
|
|
207
|
+
* scroll events, so both are 0 until the first one arrives. Unmeasured is
|
|
208
|
+
* treated as "draw it": before any scroll a screen tall enough to scroll does
|
|
209
|
+
* have content below, and a screen too short to scroll never contradicts it.
|
|
210
|
+
*
|
|
211
|
+
* Pure, so the whole ramp is reachable from `bun test`. See AGENTS.md.
|
|
212
|
+
*/
|
|
213
|
+
export function resolveFooterBorderOpacity(state: {
|
|
214
|
+
/** Live scroll offset. */
|
|
215
|
+
scrollY: number;
|
|
216
|
+
/** Total scrollable content height, or 0 before the first scroll event. */
|
|
217
|
+
contentHeight: number;
|
|
218
|
+
/** Viewport height, or 0 before the first scroll event. */
|
|
219
|
+
layoutHeight: number;
|
|
220
|
+
fadeOnScroll: boolean;
|
|
221
|
+
}): number {
|
|
222
|
+
"worklet";
|
|
223
|
+
if (!state.fadeOnScroll) return 1;
|
|
224
|
+
if (state.contentHeight <= 0) return 1;
|
|
225
|
+
|
|
226
|
+
const remaining = state.contentHeight - state.layoutHeight - state.scrollY;
|
|
227
|
+
// Written out rather than shared with the navbar's ramp — see the note there.
|
|
228
|
+
return Math.min(1, Math.max(0, remaining / SCREEN_BORDER_FADE_DISTANCE));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The four safe-area insets, as `useSafeAreaInsets()` reports them. */
|
|
232
|
+
export type ScreenEdgeInsets = { top: number; right: number; bottom: number; left: number };
|
|
233
|
+
|
|
234
|
+
/** Padding for a container insetting itself against some of the safe area. */
|
|
235
|
+
export type ScreenEdgePadding = {
|
|
236
|
+
paddingTop?: number;
|
|
237
|
+
paddingRight?: number;
|
|
238
|
+
paddingBottom?: number;
|
|
239
|
+
paddingLeft?: number;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Safe-area padding for a set of edges.
|
|
244
|
+
*
|
|
245
|
+
* Values from `useSafeAreaInsets()` rather than uniwind's `pt-safe` utilities,
|
|
246
|
+
* which compile to `env(safe-area-inset-*)` and resolve to **zero** on React
|
|
247
|
+
* Native — a silent no-op, which is the worst kind of wrong for a notch. The
|
|
248
|
+
* hook is also the same source the footer's occupancy maths reads, so a
|
|
249
|
+
* container's inset and the reserve computed against it cannot disagree.
|
|
250
|
+
*
|
|
251
|
+
* **Only the edges asked for appear.** An unrequested edge is absent, not `0`.
|
|
252
|
+
* A `0` here is not a harmless absence — it is a value, and it wins: uniwind
|
|
253
|
+
* puts a className's style first and the `style` prop second, and Yoga resolves
|
|
254
|
+
* a longhand edge ahead of the `padding` shorthand a class compiles to,
|
|
255
|
+
* treating `0` as defined. So four unconditional zeroes silently erased every
|
|
256
|
+
* side of a caller's `p-5`. Emitting a partial is half the fix; the other half
|
|
257
|
+
* is that a caller's className belongs on a different box entirely — see
|
|
258
|
+
* `screen-header.tsx`.
|
|
259
|
+
*
|
|
260
|
+
* Pure, so the whole matrix is reachable from `bun test`. See AGENTS.md.
|
|
261
|
+
*/
|
|
262
|
+
export function resolveScreenEdgePadding(
|
|
263
|
+
edges: readonly ScreenEdge[] | undefined,
|
|
264
|
+
insets: ScreenEdgeInsets
|
|
265
|
+
): ScreenEdgePadding {
|
|
266
|
+
const padding: ScreenEdgePadding = {};
|
|
267
|
+
if (!edges?.length) return padding;
|
|
268
|
+
|
|
269
|
+
if (edges.includes("top")) padding.paddingTop = insets.top;
|
|
270
|
+
if (edges.includes("right")) padding.paddingRight = insets.right;
|
|
271
|
+
if (edges.includes("bottom")) padding.paddingBottom = insets.bottom;
|
|
272
|
+
if (edges.includes("left")) padding.paddingLeft = insets.left;
|
|
273
|
+
|
|
274
|
+
return padding;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/*
|
|
278
|
+
* How much vertical space `Screen.Footer` takes away from a scrollable, kept
|
|
279
|
+
* here so the arithmetic is unit-testable and cannot drift from the footer's
|
|
280
|
+
* own render.
|
|
281
|
+
*
|
|
282
|
+
* There are TWO different numbers below and conflating them is a real bug —
|
|
283
|
+
* they differ by exactly the safe-area inset:
|
|
284
|
+
*
|
|
285
|
+
* - `footerOccupancy` — what the footer covers in LIST-CONTENT space.
|
|
286
|
+
* - `footerAboveKeyboard` — what it covers ABOVE AN OPEN KEYBOARD.
|
|
287
|
+
*
|
|
288
|
+
* ## Why occupancy is the same in both keyboard states
|
|
289
|
+
*
|
|
290
|
+
* For keyboard height `K`, safe-area inset `b`, measured content `m`, and the
|
|
291
|
+
* footer's own chrome `c = SCREEN_FOOTER_PADDING + SCREEN_FLOATING_BOTTOM_GAP`:
|
|
292
|
+
*
|
|
293
|
+
* - CLOSED: the footer's top edge sits `c + m + b` above the screen bottom, and
|
|
294
|
+
* the list's content bottom IS the screen bottom. Reserve = `c + m + b`.
|
|
295
|
+
* - OPEN: `KeyboardStickyView` translates by `-(K - b)` (its `offset.opened` is
|
|
296
|
+
* the inset), so the footer's `b` band ends up BEHIND the keyboard and its top
|
|
297
|
+
* edge is `keyboardTop - (c + m)`. The list lifts by `(K - b)`, because
|
|
298
|
+
* `KeyboardChatScrollView` scrolls by "distance (`keyboardHeight - offset`)
|
|
299
|
+
* instead of the full keyboard height" and `Screen.ChatList` passes the inset
|
|
300
|
+
* as that offset. Measured from the lifted content bottom, the footer's top is
|
|
301
|
+
* once again `c + m + b` away.
|
|
302
|
+
*
|
|
303
|
+
* So ONE static spacer is correct in both states: `b` is real padding while
|
|
304
|
+
* closed and list-lift compensation while open. Omitting it — on the theory that
|
|
305
|
+
* the sticky shift "discards" it — leaves every chat list short by the inset,
|
|
306
|
+
* correct to within 2pt on a 34pt-inset phone and 32pt wrong on a zero-inset
|
|
307
|
+
* one.
|
|
308
|
+
*
|
|
309
|
+
* `footerAboveKeyboard` is the exception, and only because it answers a
|
|
310
|
+
* different question: `KeyboardAwareScrollView`'s `bottomOffset` is the
|
|
311
|
+
* clearance between the FOCUSED INPUT and the keyboard's top edge, so the band
|
|
312
|
+
* already hidden behind the keyboard must not be counted.
|
|
313
|
+
*
|
|
314
|
+
* These stay numbers rather than joining the tokens in `tokens.css`. The footer
|
|
315
|
+
* applies them as an inline `style` precisely because `footerOccupancy` has to
|
|
316
|
+
* add the same band to a height measured at runtime, and a class is unreadable
|
|
317
|
+
* from JS. One constant driving both the render and the reserve is what stops a
|
|
318
|
+
* scrollable clearing the wrong distance.
|
|
319
|
+
*/
|
|
320
|
+
|
|
321
|
+
/** `Screen.Footer`'s padding on its top and side edges. */
|
|
322
|
+
export const SCREEN_FOOTER_PADDING = 16;
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Gap between the safe-area edge and a floating bottom surface.
|
|
326
|
+
*
|
|
327
|
+
* Separate from {@link SCREEN_FOOTER_PADDING} because the bottom edge is
|
|
328
|
+
* measured against whatever else can occupy that corner of the screen — a tab
|
|
329
|
+
* bar, a composer pill — and a few points of difference between them reads as
|
|
330
|
+
* the surface jumping on navigation.
|
|
331
|
+
*/
|
|
332
|
+
export const SCREEN_FLOATING_BOTTOM_GAP = 12;
|
|
333
|
+
|
|
334
|
+
/** Breathing room between a chat's newest message and the composer above it. */
|
|
335
|
+
export const CHAT_COMPOSER_GAP = 16;
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Vertical space the footer takes from a scrollable's content, in list-content
|
|
339
|
+
* space. Identical whether the keyboard is open or closed — see the block above.
|
|
340
|
+
*
|
|
341
|
+
* @param measuredContentHeight - `footer.height` / `footer.initialHeight`, what `Screen.Footer` measures
|
|
342
|
+
* @param safeAreaBottom - `useSafeAreaInsets().bottom`
|
|
343
|
+
*/
|
|
344
|
+
export function footerOccupancy(measuredContentHeight: number, safeAreaBottom: number): number {
|
|
345
|
+
"worklet";
|
|
346
|
+
return measuredContentHeight + SCREEN_FOOTER_PADDING + SCREEN_FLOATING_BOTTOM_GAP + safeAreaBottom;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Vertical space the footer takes ABOVE AN OPEN KEYBOARD. Excludes the
|
|
351
|
+
* safe-area band, which the sticky shift parks behind the keyboard.
|
|
352
|
+
*
|
|
353
|
+
* For `KeyboardAwareScrollView`'s `bottomOffset` — clearance between the focused
|
|
354
|
+
* input and the keyboard top, not clearance in content space.
|
|
355
|
+
*/
|
|
356
|
+
export function footerAboveKeyboard(measuredContentHeight: number): number {
|
|
357
|
+
"worklet";
|
|
358
|
+
return measuredContentHeight + SCREEN_FOOTER_PADDING + SCREEN_FLOATING_BOTTOM_GAP;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** How a scrollable reserves space at its bottom edge. See {@link resolveScrollBottomInset}. */
|
|
362
|
+
export const SCREEN_SCROLL_INSET_MODES = ["standard", "keyboard-aware", "chat"] as const;
|
|
363
|
+
|
|
364
|
+
export type ScreenScrollInsetMode = (typeof SCREEN_SCROLL_INSET_MODES)[number];
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The spacer height a scrollable needs below its content.
|
|
368
|
+
*
|
|
369
|
+
* Reserves the footer's OCCUPANCY, not its measured content height. The two
|
|
370
|
+
* differ by the footer's own chrome, so reserving the measured height alone
|
|
371
|
+
* leaves the last row sitting 28pt behind the footer's top edge — visible under
|
|
372
|
+
* `<Screen debug>` as a red band above the green one.
|
|
373
|
+
*
|
|
374
|
+
* A `static` footer needs no reserve at all: it sits in the flow below the
|
|
375
|
+
* scrollable and carries its own safe-area padding, so counting it here would
|
|
376
|
+
* strand an empty band the height of the footer.
|
|
377
|
+
*
|
|
378
|
+
* With no footer mounted the reserve is the bare safe-area band, which fades out
|
|
379
|
+
* as the keyboard covers it — the inset is meaningless behind a keyboard.
|
|
380
|
+
*
|
|
381
|
+
* Pure, so the whole matrix is reachable from `bun test`. See AGENTS.md.
|
|
382
|
+
*/
|
|
383
|
+
export function resolveScrollBottomInset(state: {
|
|
384
|
+
mode: ScreenScrollInsetMode;
|
|
385
|
+
/** `footer.height` — content only, excluding the footer's padding and safe-area band. */
|
|
386
|
+
footerHeight: number;
|
|
387
|
+
footerPlacement: ScreenPlacement;
|
|
388
|
+
/** `useSafeAreaInsets().bottom`. */
|
|
389
|
+
safeAreaBottom: number;
|
|
390
|
+
/** 0 closed through 1 open. */
|
|
391
|
+
keyboardProgress: number;
|
|
392
|
+
/** Negative while the keyboard is open. */
|
|
393
|
+
keyboardHeight: number;
|
|
394
|
+
}): number {
|
|
395
|
+
"worklet";
|
|
396
|
+
const hasFooter = state.footerHeight > 0;
|
|
397
|
+
|
|
398
|
+
if (state.mode === "chat") {
|
|
399
|
+
// A chat list is LIFTED by the keyboard rather than padded for it, so no
|
|
400
|
+
// keyboard band belongs here — and the occupancy is correct in both
|
|
401
|
+
// keyboard states, which is why the safe-area inset is not faded either.
|
|
402
|
+
const reserve = hasFooter ? footerOccupancy(state.footerHeight, state.safeAreaBottom) : state.safeAreaBottom;
|
|
403
|
+
return reserve + CHAT_COMPOSER_GAP;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const fadingSafeArea = state.safeAreaBottom * (1 - state.keyboardProgress);
|
|
407
|
+
// `keyboardHeight` is negative while open, so the band is its negation —
|
|
408
|
+
// clamped, because negating a closed keyboard's 0 yields -0, and a -0 height
|
|
409
|
+
// reaching a native view is a surprise waiting to happen.
|
|
410
|
+
//
|
|
411
|
+
// `keyboard-aware` omits the band entirely: `KeyboardAwareScrollView` adds
|
|
412
|
+
// that padding itself, and adding it twice leaves a keyboard-sized gap.
|
|
413
|
+
const keyboardBand = state.mode === "keyboard-aware" ? 0 : Math.max(0, -state.keyboardHeight);
|
|
414
|
+
|
|
415
|
+
if (!hasFooter) return fadingSafeArea + keyboardBand;
|
|
416
|
+
if (state.footerPlacement === "static") return keyboardBand;
|
|
417
|
+
|
|
418
|
+
return footerOccupancy(state.footerHeight, fadingSafeArea) + keyboardBand;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* The height of a scrollable's top spacer.
|
|
423
|
+
*
|
|
424
|
+
* Mirrors the top half of {@link resolveScreenViewPadding}, including the
|
|
425
|
+
* fallback that matters most: with NO navbar mounted the spacer is the raw
|
|
426
|
+
* safe-area inset, not zero. Without it a screen that skips the navbar runs its
|
|
427
|
+
* first row under the notch — the scroll area had no fallback while the static
|
|
428
|
+
* body did, so the two disagreed about the same screen.
|
|
429
|
+
*
|
|
430
|
+
* A `static` navbar contributes nothing: it already took its space in the flow.
|
|
431
|
+
*
|
|
432
|
+
* Pure, so the whole matrix is reachable from `bun test`. See AGENTS.md.
|
|
433
|
+
*/
|
|
434
|
+
export function resolveScrollTopInset(state: {
|
|
435
|
+
navbarHeight: number;
|
|
436
|
+
navbarPlacement: ScreenPlacement;
|
|
437
|
+
safeAreaTop: number;
|
|
438
|
+
}): number {
|
|
439
|
+
"worklet";
|
|
440
|
+
if (state.navbarHeight <= 0) return state.safeAreaTop;
|
|
441
|
+
return state.navbarPlacement === "overlay" ? state.navbarHeight : 0;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* The padding a non-scrolling body needs to clear the screen's chrome.
|
|
446
|
+
*
|
|
447
|
+
* Falls back to the raw safe-area inset on an edge with no chrome on it, so a
|
|
448
|
+
* screen with no navbar still clears the notch. A `static` navbar or footer
|
|
449
|
+
* contributes nothing: it already took its space in the flow.
|
|
450
|
+
*
|
|
451
|
+
* Pure, so the whole matrix is reachable from `bun test`. See AGENTS.md.
|
|
452
|
+
*/
|
|
453
|
+
export function resolveScreenViewPadding(state: {
|
|
454
|
+
navbarHeight: number;
|
|
455
|
+
navbarPlacement: ScreenPlacement;
|
|
456
|
+
footerHeight: number;
|
|
457
|
+
footerPlacement: ScreenPlacement;
|
|
458
|
+
safeAreaTop: number;
|
|
459
|
+
safeAreaBottom: number;
|
|
460
|
+
}): { paddingTop: number; paddingBottom: number } {
|
|
461
|
+
"worklet";
|
|
462
|
+
const hasNavbar = state.navbarHeight > 0;
|
|
463
|
+
const paddingTop = hasNavbar ? (state.navbarPlacement === "overlay" ? state.navbarHeight : 0) : state.safeAreaTop;
|
|
464
|
+
|
|
465
|
+
const hasFooter = state.footerHeight > 0;
|
|
466
|
+
const paddingBottom = hasFooter
|
|
467
|
+
? state.footerPlacement === "overlay"
|
|
468
|
+
? footerOccupancy(state.footerHeight, state.safeAreaBottom)
|
|
469
|
+
: 0
|
|
470
|
+
: state.safeAreaBottom;
|
|
471
|
+
|
|
472
|
+
return { paddingBottom, paddingTop: paddingTop };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export type ScreenVariantProps = VariantProps<typeof screenVariants>;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
|
3
|
+
import {
|
|
4
|
+
type DerivedValue,
|
|
5
|
+
type SharedValue,
|
|
6
|
+
useAnimatedReaction,
|
|
7
|
+
useAnimatedScrollHandler,
|
|
8
|
+
useAnimatedStyle,
|
|
9
|
+
useDerivedValue,
|
|
10
|
+
} from "react-native-reanimated";
|
|
11
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
12
|
+
import { scheduleOnRN } from "react-native-worklets";
|
|
13
|
+
import { useScreenPart } from "./screen.context";
|
|
14
|
+
import {
|
|
15
|
+
CHAT_COMPOSER_GAP,
|
|
16
|
+
footerAboveKeyboard,
|
|
17
|
+
footerOccupancy,
|
|
18
|
+
resolveScrollBottomInset,
|
|
19
|
+
resolveScrollTopInset,
|
|
20
|
+
type ScreenScrollInsetMode,
|
|
21
|
+
} from "./screen.variants";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The footer's collapsed baseline height, as JS state.
|
|
25
|
+
*
|
|
26
|
+
* The measurement arrives on the UI thread, so it is mirrored across rather
|
|
27
|
+
* than read directly — a `.value` captured in a memo or effect closure becomes
|
|
28
|
+
* a memo-cache dependency read during render once React Compiler is on, which
|
|
29
|
+
* Reanimated's strict mode rejects.
|
|
30
|
+
*
|
|
31
|
+
* A measured 0 means NOT MEASURED YET — mount, or the footer unmounting — and
|
|
32
|
+
* is ignored, so a caller's seed survives until a real height replaces it.
|
|
33
|
+
*/
|
|
34
|
+
function useFooterBaselineHeight(estimatedHeight = 0): number {
|
|
35
|
+
const { footer } = useScreenPart("a Screen scrollable");
|
|
36
|
+
const [measured, setMeasured] = useState(() => Math.round(estimatedHeight));
|
|
37
|
+
|
|
38
|
+
useAnimatedReaction(
|
|
39
|
+
() => Math.round(footer.initialHeight.value),
|
|
40
|
+
(current, previous) => {
|
|
41
|
+
if (current <= 0 || current === previous) return;
|
|
42
|
+
scheduleOnRN(setMeasured, current);
|
|
43
|
+
},
|
|
44
|
+
[]
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
return measured;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The shared value a footer's own content drives when it expands VISUALLY above
|
|
52
|
+
* its measured layout box.
|
|
53
|
+
*
|
|
54
|
+
* For a composer pill that grows upward without changing the height it
|
|
55
|
+
* occupies. Drive it as a discrete reservation — the full expanded height while
|
|
56
|
+
* open, 0 when closed — and set it BEFORE focusing the input, so a chat
|
|
57
|
+
* scrollable sees the right padding on the same frame it starts lifting. Do NOT
|
|
58
|
+
* interpolate it with keyboard progress: a mid-animation write races the lift
|
|
59
|
+
* and can overwrite it with a stale offset.
|
|
60
|
+
*
|
|
61
|
+
* Growth while the keyboard is already open is fine — those deltas are small.
|
|
62
|
+
* Reset it to 0 on unmount; `Screen.Footer` clears the measured heights but
|
|
63
|
+
* never this one, because it does not own it.
|
|
64
|
+
*/
|
|
65
|
+
export function useScreenFooterOverlayHeight(): SharedValue<number> {
|
|
66
|
+
const { footer } = useScreenPart("useScreenFooterOverlayHeight");
|
|
67
|
+
return footer.overlayHeight;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Live composer clearance as a UI-thread value: the footer's full occupancy,
|
|
72
|
+
* plus whatever overlays above it, plus the breathing gap.
|
|
73
|
+
*
|
|
74
|
+
* Feed this to a chat list's own inset props so composer growth and keyboard
|
|
75
|
+
* motion land in the SAME frame. Routing it through React state instead lags
|
|
76
|
+
* the keyboard by a re-render, which reads as jank.
|
|
77
|
+
*/
|
|
78
|
+
export function useChatComposerInset(): DerivedValue<number> {
|
|
79
|
+
const { footer } = useScreenPart("useChatComposerInset");
|
|
80
|
+
const { bottom } = useSafeAreaInsets();
|
|
81
|
+
|
|
82
|
+
return useDerivedValue(
|
|
83
|
+
() => footerOccupancy(footer.height.value, bottom) + footer.overlayHeight.value + CHAT_COMPOSER_GAP,
|
|
84
|
+
[bottom]
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The composer's growth above its collapsed baseline, as a UI-thread value.
|
|
90
|
+
*
|
|
91
|
+
* A growing input extends the scroll range through this rather than through a
|
|
92
|
+
* re-render, so the list never re-lays-out mid-keystroke.
|
|
93
|
+
*/
|
|
94
|
+
export function useChatComposerGrowthPadding(): DerivedValue<number> {
|
|
95
|
+
const { footer } = useScreenPart("useChatComposerGrowthPadding");
|
|
96
|
+
|
|
97
|
+
return useDerivedValue(
|
|
98
|
+
() => Math.max(0, footer.height.value - footer.initialHeight.value) + footer.overlayHeight.value,
|
|
99
|
+
[]
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type { ScreenScrollInsetMode };
|
|
104
|
+
|
|
105
|
+
/** A chat list's static bottom clearance, split so each band is separately visible under `<Screen debug>`. */
|
|
106
|
+
export type ChatComposerSpacer = {
|
|
107
|
+
/** Exactly what the footer covers — the band whose edge must land on the footer's own. */
|
|
108
|
+
occupancy: number;
|
|
109
|
+
/** Breathing room between the newest message and the composer, above `occupancy`. */
|
|
110
|
+
gap: number;
|
|
111
|
+
/** `occupancy + gap` — the whole reserve. */
|
|
112
|
+
total: number;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The STATIC layout spacer a chat list reserves for the composer. Live growth
|
|
117
|
+
* and keyboard motion go through the UI-thread values above, never here.
|
|
118
|
+
*
|
|
119
|
+
* The safe-area band IS included: it is correct in both keyboard states, and
|
|
120
|
+
* the derivation is in `screen.variants`. Omitting it leaves every chat list
|
|
121
|
+
* short by exactly the inset.
|
|
122
|
+
*
|
|
123
|
+
* `estimatedFooterHeight` SEEDS the first render and is read once — later
|
|
124
|
+
* changes are ignored. Pass it whenever the composer's collapsed height is a
|
|
125
|
+
* known constant. The footer only publishes its real height a commit or two
|
|
126
|
+
* AFTER the list's first layout, and a virtualised list cannot always correct
|
|
127
|
+
* for that afterwards: once the footer has grown it reads the shifted offset as
|
|
128
|
+
* "the user scrolled away" and abandons its retarget. That is why the newest
|
|
129
|
+
* message hides under the composer only SOMETIMES. A correct first paint is the
|
|
130
|
+
* only reliable fix.
|
|
131
|
+
*/
|
|
132
|
+
export function useChatComposerBaseSpacerHeight(estimatedFooterHeight = 0): ChatComposerSpacer {
|
|
133
|
+
const { bottom } = useSafeAreaInsets();
|
|
134
|
+
const measured = useFooterBaselineHeight(estimatedFooterHeight);
|
|
135
|
+
const occupancy = footerOccupancy(measured, bottom);
|
|
136
|
+
|
|
137
|
+
return { gap: CHAT_COMPOSER_GAP, occupancy, total: occupancy + CHAT_COMPOSER_GAP };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Clearance a keyboard-aware scrollable needs between its focused input and the
|
|
142
|
+
* keyboard's top edge.
|
|
143
|
+
*
|
|
144
|
+
* A sticky footer rides the keyboard, so the input has to clear the footer too,
|
|
145
|
+
* not just the keyboard. Deliberately NOT the full occupancy: the sticky shift
|
|
146
|
+
* parks the footer's safe-area band behind the keyboard, so counting it here
|
|
147
|
+
* would scroll the input further than it needs to go.
|
|
148
|
+
*/
|
|
149
|
+
export function useScreenFooterKeyboardClearance(): number {
|
|
150
|
+
return footerAboveKeyboard(useFooterBaselineHeight()) + CHAT_COMPOSER_GAP;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The shared scroll reporting and top/bottom spacer styles every Screen
|
|
155
|
+
* scrollable is built on.
|
|
156
|
+
*
|
|
157
|
+
* One hook rather than a copy per list, so `Screen.ScrollArea`,
|
|
158
|
+
* `Screen.FlatList`, `Screen.SectionList` and the rest cannot drift on what
|
|
159
|
+
* "clear the navbar" means.
|
|
160
|
+
*
|
|
161
|
+
* Both insets are spacer HEIGHTS rather than content padding. A padded content
|
|
162
|
+
* container cannot animate on the UI thread, and the navbar's height is only
|
|
163
|
+
* known after it lays out — so a spacer view whose height is an animated style
|
|
164
|
+
* is what lets the reserve appear in the same frame as the measurement.
|
|
165
|
+
*/
|
|
166
|
+
export function useScreenScrollInsets(mode: ScreenScrollInsetMode) {
|
|
167
|
+
const { navbar, footer, scrollY, contentHeight, layoutHeight } = useScreenPart("a Screen scrollable");
|
|
168
|
+
const { bottom, top } = useSafeAreaInsets();
|
|
169
|
+
const keyboard = useReanimatedKeyboardAnimation();
|
|
170
|
+
|
|
171
|
+
const scrollHandler = useAnimatedScrollHandler({
|
|
172
|
+
onScroll: (event) => {
|
|
173
|
+
scrollY.value = event.contentOffset.y;
|
|
174
|
+
|
|
175
|
+
if (event.contentSize.height !== contentHeight.value) {
|
|
176
|
+
contentHeight.value = event.contentSize.height;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (event.layoutMeasurement.height !== layoutHeight.value) {
|
|
180
|
+
layoutHeight.value = event.layoutMeasurement.height;
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const insetTopAnimatedStyle = useAnimatedStyle(() => {
|
|
186
|
+
return {
|
|
187
|
+
height: resolveScrollTopInset({
|
|
188
|
+
navbarHeight: navbar.height.value,
|
|
189
|
+
navbarPlacement: navbar.placement.value,
|
|
190
|
+
safeAreaTop: top,
|
|
191
|
+
}),
|
|
192
|
+
};
|
|
193
|
+
}, [top]);
|
|
194
|
+
|
|
195
|
+
const insetBottomAnimatedStyle = useAnimatedStyle(() => {
|
|
196
|
+
return {
|
|
197
|
+
height: resolveScrollBottomInset({
|
|
198
|
+
footerHeight: footer.height.value,
|
|
199
|
+
footerPlacement: footer.placement.value,
|
|
200
|
+
keyboardHeight: keyboard.height.value,
|
|
201
|
+
keyboardProgress: keyboard.progress.value,
|
|
202
|
+
mode,
|
|
203
|
+
safeAreaBottom: bottom,
|
|
204
|
+
}),
|
|
205
|
+
};
|
|
206
|
+
}, [mode, bottom]);
|
|
207
|
+
|
|
208
|
+
return { insetBottomAnimatedStyle, insetTopAnimatedStyle, scrollHandler };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The scroll handler and spacer styles a Screen scrollable is built from.
|
|
213
|
+
*
|
|
214
|
+
* Inferred rather than written out: `useAnimatedStyle`'s return type carries
|
|
215
|
+
* Reanimated's own internal branding, and restating it here by hand would be a
|
|
216
|
+
* second definition that can drift from the one Reanimated actually returns.
|
|
217
|
+
*/
|
|
218
|
+
export type ScreenScrollInsets = ReturnType<typeof useScreenScrollInsets>;
|