@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,271 @@
|
|
|
1
|
+
import type { VariantProps } from "tailwind-variants";
|
|
2
|
+
import { tv } from "../../lib/tv";
|
|
3
|
+
import type { TextSize } from "../text/text.variants";
|
|
4
|
+
|
|
5
|
+
/** How the ring is painted. `secondary` fills it; `primary` leaves it hollow. */
|
|
6
|
+
export const RADIO_VARIANTS = ["primary", "secondary"] as const;
|
|
7
|
+
|
|
8
|
+
export const RADIO_SIZES = ["sm", "md", "lg"] as const;
|
|
9
|
+
|
|
10
|
+
/** Which way a `Radio.Group` lays its radios out. */
|
|
11
|
+
export const RADIO_ORIENTATIONS = ["vertical", "horizontal"] as const;
|
|
12
|
+
|
|
13
|
+
export type RadioVariant = (typeof RADIO_VARIANTS)[number];
|
|
14
|
+
export type RadioSize = (typeof RADIO_SIZES)[number];
|
|
15
|
+
export type RadioOrientation = (typeof RADIO_ORIENTATIONS)[number];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The axes a radio falls back to when neither it nor its group names one.
|
|
19
|
+
*
|
|
20
|
+
* Named once because two places read them — `defaultVariants` below and
|
|
21
|
+
* {@link resolveRadioState}, which runs before `tv` is ever called. A test pins
|
|
22
|
+
* the pair, since a drift between them is a radio that renders at one size and
|
|
23
|
+
* reports another.
|
|
24
|
+
*/
|
|
25
|
+
export const RADIO_DEFAULT_SIZE: RadioSize = "md";
|
|
26
|
+
export const RADIO_DEFAULT_VARIANT: RadioVariant = "primary";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The spring the dot scales in and out on.
|
|
30
|
+
*
|
|
31
|
+
* Deliberately near `Pressable`'s `PRESS_SPRING` but a touch looser, so the dot
|
|
32
|
+
* settles just after the row it sits in has rebounded. Read on the JS thread
|
|
33
|
+
* inside an effect rather than captured by a worklet, so it stays a plain object.
|
|
34
|
+
*/
|
|
35
|
+
export const RADIO_DOT_SPRING = { damping: 16, mass: 0.4, stiffness: 300 } as const;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The `Text` size step each radio size hands its label.
|
|
39
|
+
*
|
|
40
|
+
* `Radio.Label` renders `Text.Label` and names this step, rather than restating
|
|
41
|
+
* a type scale in the `label` slot. `Text`'s own size axis is built to beat its
|
|
42
|
+
* preset, so this keeps the weight and the colour in exactly one place — the
|
|
43
|
+
* preset — while still letting the label track the radio's size. A test asserts
|
|
44
|
+
* every value here is a size `Text` actually has.
|
|
45
|
+
*/
|
|
46
|
+
export const RADIO_LABEL_TEXT_SIZE: Record<RadioSize, TextSize> = { sm: "sm", md: "md", lg: "lg" };
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Styling for every part of a radio, and for the group that holds one.
|
|
50
|
+
*
|
|
51
|
+
* One slotted `tv()` rather than a call per part, because `radio-indicator.tsx`
|
|
52
|
+
* and `radio-label.tsx` cannot import the root without closing a cycle (AGENTS.md
|
|
53
|
+
* rule 3) yet all three read the same `size`, `variant` and `isSelected`.
|
|
54
|
+
*
|
|
55
|
+
* **The root takes no `self-start` and no `w-full`, and both absences are
|
|
56
|
+
* load-bearing.** A radio row *is* its own tap target, so unlike a `Badge` it
|
|
57
|
+
* wants the stretch it gets inside the group's `flex-col` — `self-start` would
|
|
58
|
+
* shrink the target to the width of the word "Yes". But unlike a `ListGroup.Item`
|
|
59
|
+
* it cannot take `w-full` either, because a `horizontal` group would then give
|
|
60
|
+
* every radio the full width of the group and blow the row apart. Vertical
|
|
61
|
+
* stretch already supplies the width; horizontal wants content width.
|
|
62
|
+
*
|
|
63
|
+
* **The label is `shrink`, never `flex-1`.** `flex-1` sets `flex-basis: 0%`, and
|
|
64
|
+
* in a content-sized `horizontal` row Yoga resolves that to zero and collapses
|
|
65
|
+
* the text to nothing. `shrink` lets a long label wrap without claiming a basis.
|
|
66
|
+
*
|
|
67
|
+
* **The row's height is a floor, never fixed.** `Text` respects OS font scaling,
|
|
68
|
+
* so `h-*` would clip a label at a large accessibility step; `min-h-*` exists for
|
|
69
|
+
* the hit target, which scaling may exceed but must never undercut.
|
|
70
|
+
*
|
|
71
|
+
* The ring indexes the shared `--spacing-icon-*` scale rather than minting one of
|
|
72
|
+
* its own — a radio's ring is a small round mark in a row beside a label, the
|
|
73
|
+
* same kind of thing as a row's chevron, and it should stay level with an `Icon`
|
|
74
|
+
* at the same step by construction. The dot takes plain spacing steps and its
|
|
75
|
+
* fit inside the ring is pinned by a test rather than by a token, the way
|
|
76
|
+
* `Field` pins its gap ladder.
|
|
77
|
+
*
|
|
78
|
+
* `border-2` sits in the base rather than on a variant: a border declared only
|
|
79
|
+
* where it is coloured would make the ring four points smaller the moment a
|
|
80
|
+
* caller switched variant.
|
|
81
|
+
*
|
|
82
|
+
* No slot worn by a `View` carries `text-*`, and the `label` slot carries no type
|
|
83
|
+
* scale, weight or colour at all — see {@link RADIO_LABEL_TEXT_SIZE}.
|
|
84
|
+
*
|
|
85
|
+
* Free of React Native imports so it stays unit-testable — `bun test` cannot
|
|
86
|
+
* parse React Native's Flow-typed source. See AGENTS.md.
|
|
87
|
+
*/
|
|
88
|
+
export const radioVariants = tv({
|
|
89
|
+
slots: {
|
|
90
|
+
/** The group's box. Paints no state of its own — see the note below. */
|
|
91
|
+
group: "w-full",
|
|
92
|
+
/** The pressable row. Deliberately neither `self-start` nor `w-full`. */
|
|
93
|
+
root: "flex-row items-center",
|
|
94
|
+
/** The ring. Its colour is a class, so the whole matrix stays in `bun test`. */
|
|
95
|
+
indicator: "items-center justify-center rounded-full border-2",
|
|
96
|
+
/** The inner dot. Its scale and opacity are an animated style, never a class. */
|
|
97
|
+
dot: "rounded-full bg-primary",
|
|
98
|
+
/** Handed to a `Text.Label`. Layout only. */
|
|
99
|
+
label: "shrink",
|
|
100
|
+
},
|
|
101
|
+
variants: {
|
|
102
|
+
orientation: {
|
|
103
|
+
vertical: { group: "flex-col" },
|
|
104
|
+
horizontal: { group: "flex-row flex-wrap items-center" },
|
|
105
|
+
},
|
|
106
|
+
size: {
|
|
107
|
+
sm: { group: "gap-2", root: "min-h-9 gap-2", indicator: "size-icon-md", dot: "size-1.5" },
|
|
108
|
+
md: { group: "gap-3", root: "min-h-11 gap-2.5", indicator: "size-icon-lg", dot: "size-2" },
|
|
109
|
+
lg: { group: "gap-3.5", root: "min-h-12 gap-3", indicator: "size-icon-xl", dot: "size-2.5" },
|
|
110
|
+
},
|
|
111
|
+
// The empty branches are load-bearing typing, not placeholders. `tv`
|
|
112
|
+
// derives the prop type from the declared keys, so a map with only `true`
|
|
113
|
+
// types the prop as `true` rather than `boolean`. See button.variants.ts.
|
|
114
|
+
variant: { primary: {}, secondary: {} },
|
|
115
|
+
isSelected: { true: {}, false: {} },
|
|
116
|
+
isInvalid: { true: {}, false: {} },
|
|
117
|
+
// A trailing ring turns the row into a settings row: the content takes the
|
|
118
|
+
// left, the ring the far right. Declared here rather than left to a
|
|
119
|
+
// caller's `justify-between` because the root already knows where the
|
|
120
|
+
// indicator landed — see {@link resolveIndicatorPlacement}.
|
|
121
|
+
isIndicatorTrailing: { true: { root: "justify-between" }, false: {} },
|
|
122
|
+
// The fade lands on the children, never on the `root`. `root` is worn by
|
|
123
|
+
// `Pressable`'s own `Animated.View`, which now multiplies a className's
|
|
124
|
+
// opacity into its press — but the ring and the label fading on their own
|
|
125
|
+
// keeps the row's hit area at full contrast and lets each part be tuned,
|
|
126
|
+
// so it stays here. Putting `opacity-50` on the root as well would
|
|
127
|
+
// compound with these and land at a quarter.
|
|
128
|
+
//
|
|
129
|
+
// The group must not fade either, or a disabled group would compound
|
|
130
|
+
// `opacity-50` with each of its rows and land at a quarter opacity.
|
|
131
|
+
isDisabled: { true: { indicator: "opacity-50", label: "opacity-50" }, false: {} },
|
|
132
|
+
},
|
|
133
|
+
compoundVariants: [
|
|
134
|
+
// Neither `variant` nor `isSelected` paints the ring alone, so all four
|
|
135
|
+
// cells live here — the reason a badge's twenty-four do.
|
|
136
|
+
{ variant: "primary", isSelected: false, class: { indicator: "border-input bg-transparent" } },
|
|
137
|
+
{ variant: "primary", isSelected: true, class: { indicator: "border-primary bg-transparent" } },
|
|
138
|
+
{ variant: "secondary", isSelected: false, class: { indicator: "border-border bg-secondary" } },
|
|
139
|
+
{ variant: "secondary", isSelected: true, class: { indicator: "border-primary bg-secondary" } },
|
|
140
|
+
// Invalid outranks selected, and it is a compound rather than a plain
|
|
141
|
+
// variant purely for emission order: `tv` emits the variants first and the
|
|
142
|
+
// compounds after, so a plain `isInvalid` branch would lose to the four
|
|
143
|
+
// cells above. `Input` leans on the same mechanism for focused-and-invalid.
|
|
144
|
+
{ isInvalid: true, class: { indicator: "border-destructive", dot: "bg-destructive" } },
|
|
145
|
+
],
|
|
146
|
+
defaultVariants: {
|
|
147
|
+
orientation: "vertical",
|
|
148
|
+
size: RADIO_DEFAULT_SIZE,
|
|
149
|
+
variant: RADIO_DEFAULT_VARIANT,
|
|
150
|
+
isSelected: false,
|
|
151
|
+
isInvalid: false,
|
|
152
|
+
isDisabled: false,
|
|
153
|
+
isIndicatorTrailing: false,
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Where the caller put the indicator among a radio's children.
|
|
159
|
+
*
|
|
160
|
+
* `none` means they wrote no indicator at all, so the root composes one in at the
|
|
161
|
+
* front. `end` means they placed one last with something before it — a settings
|
|
162
|
+
* row, `[label and description] [ring]` — and the row spreads to push the ring to
|
|
163
|
+
* the far edge. A lone indicator with nothing beside it is `start`: there is
|
|
164
|
+
* nothing to spread it away from.
|
|
165
|
+
*
|
|
166
|
+
* This is what makes the trailing ring work without a `flex-1` spacer wedged
|
|
167
|
+
* between the two, which is the shape a caller would otherwise have to reach for.
|
|
168
|
+
*
|
|
169
|
+
* Takes an array of "is this child an indicator" rather than the children
|
|
170
|
+
* themselves, so it stays free of React and reachable from `bun test` — the trade
|
|
171
|
+
* `resolveSpinnerSwapIndex` already makes.
|
|
172
|
+
*/
|
|
173
|
+
export function resolveIndicatorPlacement(isIndicator: readonly boolean[]): "start" | "end" | "none" {
|
|
174
|
+
if (!isIndicator.some(Boolean)) return "none";
|
|
175
|
+
if (isIndicator.length > 1 && isIndicator[isIndicator.length - 1]) return "end";
|
|
176
|
+
return "start";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** What an enclosing `Radio.Group` publishes, or null for a radio standing alone. */
|
|
180
|
+
export type RadioGroupState = {
|
|
181
|
+
/** The selected value. `null` is "nothing selected", never `undefined`. */
|
|
182
|
+
selected: string | null;
|
|
183
|
+
size: RadioSize;
|
|
184
|
+
variant: RadioVariant;
|
|
185
|
+
/** Raw rather than resolved, so `??` can see past a group that named nothing. */
|
|
186
|
+
isDisabled?: boolean;
|
|
187
|
+
isInvalid?: boolean;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** What a radio was given at its own call site. */
|
|
191
|
+
export type RadioOwnState = {
|
|
192
|
+
value?: string;
|
|
193
|
+
isSelected?: boolean;
|
|
194
|
+
isDisabled?: boolean;
|
|
195
|
+
isInvalid?: boolean;
|
|
196
|
+
size?: RadioSize;
|
|
197
|
+
variant?: RadioVariant;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/** What an enclosing `Field` publishes, or null outside one. */
|
|
201
|
+
export type RadioFieldState = { isDisabled?: boolean; isInvalid?: boolean };
|
|
202
|
+
|
|
203
|
+
/** Every axis settled, ready to hand to {@link radioVariants} and to context. */
|
|
204
|
+
export type RadioState = {
|
|
205
|
+
size: RadioSize;
|
|
206
|
+
variant: RadioVariant;
|
|
207
|
+
isSelected: boolean;
|
|
208
|
+
isDisabled: boolean;
|
|
209
|
+
isInvalid: boolean;
|
|
210
|
+
/** Whether a `Radio.Group` is driving this radio. */
|
|
211
|
+
isGrouped: boolean;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Settles a radio's axes from the three places they can come from.
|
|
216
|
+
*
|
|
217
|
+
* **Nearest wins**, the ladder `Input` already runs: a `Radio.Group` first, the
|
|
218
|
+
* radio's own props next, an enclosing `Field` last. A field with no props of its
|
|
219
|
+
* own turns destructive with the `Field` around it, while `<Radio isInvalid={false} />`
|
|
220
|
+
* still opts out of one.
|
|
221
|
+
*
|
|
222
|
+
* The two axes a group owns outright — `size` and `variant` — are published
|
|
223
|
+
* resolved, so a grouped radio's own copies are ignored: a group whose options
|
|
224
|
+
* were different sizes is not a design. The two *state* axes are published raw,
|
|
225
|
+
* which is the one place this differs from `Input.Group`, and deliberately: a
|
|
226
|
+
* group holds many radios, so disabling one option out of five has to be
|
|
227
|
+
* possible. A group that names the axis still wins outright.
|
|
228
|
+
*
|
|
229
|
+
* Selection is the group's to decide when there is one. A radio carrying no
|
|
230
|
+
* `value` inside a group can never be selected — the dev warning for that lives
|
|
231
|
+
* in `radio.tsx`, where the component name is available to name in the message.
|
|
232
|
+
*
|
|
233
|
+
* Pure, so the whole matrix is reachable from `bun test`. `Input` runs this same
|
|
234
|
+
* ladder inline in its own render, where no unit test can see it. See AGENTS.md.
|
|
235
|
+
*/
|
|
236
|
+
export function resolveRadioState({
|
|
237
|
+
group,
|
|
238
|
+
own,
|
|
239
|
+
field,
|
|
240
|
+
}: {
|
|
241
|
+
group?: RadioGroupState | null;
|
|
242
|
+
own?: RadioOwnState;
|
|
243
|
+
field?: RadioFieldState | null;
|
|
244
|
+
}): RadioState {
|
|
245
|
+
const isGrouped = group != null;
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
size: group?.size ?? own?.size ?? RADIO_DEFAULT_SIZE,
|
|
249
|
+
variant: group?.variant ?? own?.variant ?? RADIO_DEFAULT_VARIANT,
|
|
250
|
+
isDisabled: group?.isDisabled ?? own?.isDisabled ?? field?.isDisabled ?? false,
|
|
251
|
+
isInvalid: group?.isInvalid ?? own?.isInvalid ?? field?.isInvalid ?? false,
|
|
252
|
+
isSelected: isGrouped ? own?.value !== undefined && group.selected === own.value : (own?.isSelected ?? false),
|
|
253
|
+
isGrouped,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Whether a press should write a new selection at all.
|
|
259
|
+
*
|
|
260
|
+
* Re-picking the option already selected is not a change, and a radio group has
|
|
261
|
+
* no deselect gesture — so without this a tap on the current selection would
|
|
262
|
+
* re-notify the caller with a value it already holds. HTML's own radio does not
|
|
263
|
+
* fire `change` there either.
|
|
264
|
+
*
|
|
265
|
+
* Pure, so it is reachable from `bun test`. See AGENTS.md.
|
|
266
|
+
*/
|
|
267
|
+
export function shouldEmitSelection(current: string | null, next: string): boolean {
|
|
268
|
+
return current !== next;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export type RadioVariantProps = VariantProps<typeof radioVariants>;
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# Screen
|
|
2
|
+
|
|
3
|
+
A screen's frame: pinned chrome, a content region, and whatever scrolls between
|
|
4
|
+
them. Compound root plus `Screen.Navbar` (itself compound), `Screen.Content`,
|
|
5
|
+
`Screen.View`, `Screen.Header`, `Screen.Footer`, four scrollables, and the two
|
|
6
|
+
whole-screen states.
|
|
7
|
+
|
|
8
|
+
`import { Screen } from "@registry/ui/screen";`
|
|
9
|
+
|
|
10
|
+
## Files
|
|
11
|
+
|
|
12
|
+
| File | What it holds |
|
|
13
|
+
| --- | --- |
|
|
14
|
+
| `index.ts` | → `@registry/ui/screen` |
|
|
15
|
+
| `screen.tsx` | The `Object.assign` compound surface |
|
|
16
|
+
| `screen-root.tsx` | The root box and its provider — see below for why it is split out |
|
|
17
|
+
| `screen-navbar.tsx` | `Screen.Navbar`, plus its own nested surface |
|
|
18
|
+
| `screen-navbar-title.tsx` | `Screen.Navbar.Title` |
|
|
19
|
+
| `screen-navbar-subtitle.tsx` | `Screen.Navbar.Subtitle` |
|
|
20
|
+
| `screen-navbar-back-button.tsx` | `Screen.Navbar.BackButton`, and `SCREEN_BACK_BUTTON_GLYPHS` |
|
|
21
|
+
| `screen-navbar-background.tsx` | The navbar's opaque backing and its bottom hairline — internal |
|
|
22
|
+
| `screen-content.tsx` | `Screen.Content` |
|
|
23
|
+
| `screen-view.tsx` | `Screen.View` |
|
|
24
|
+
| `screen-header.tsx` | `Screen.Header` |
|
|
25
|
+
| `screen-footer.tsx` | `Screen.Footer` |
|
|
26
|
+
| `screen-footer-background.tsx` | The footer's backing and its top hairline — internal |
|
|
27
|
+
| `screen-scroll-shadow.tsx` | `Screen.ScrollShadow`, and `SCROLL_SHADOW_EDGES` |
|
|
28
|
+
| `screen-scroll-area.tsx` | `Screen.ScrollArea` |
|
|
29
|
+
| `screen-flat-list.tsx` | `Screen.FlatList` |
|
|
30
|
+
| `screen-section-list.tsx` | `Screen.SectionList` |
|
|
31
|
+
| `screen-legend-list.tsx` | `Screen.LegendList` |
|
|
32
|
+
| `screen-chat-list.tsx` | `Screen.ChatList` |
|
|
33
|
+
| `screen-loading.tsx` | `Screen.Loading` |
|
|
34
|
+
| `screen-error.tsx` | `Screen.Error` |
|
|
35
|
+
| `screen-list-component.tsx` | A list header/footer prop, as a node — internal |
|
|
36
|
+
| `screen-debug.ts` | `SCREEN_DEBUG_COLORS` |
|
|
37
|
+
| `screen.context.tsx` | `ScreenProvider`, `useScreen()`, `useScreenContext()`, `useScreenPart()`, `useScreenDebug()` |
|
|
38
|
+
| `screen.types.ts` | Prop types shared by two or more parts |
|
|
39
|
+
| `screen.variants.ts` | Pure `tv()` slots + the footer maths, no RN imports |
|
|
40
|
+
| `screen.variants.test.ts` | |
|
|
41
|
+
| `use-screen-scroll-insets.ts` | The reserve every scrollable shares |
|
|
42
|
+
|
|
43
|
+
## Design
|
|
44
|
+
|
|
45
|
+
- **The navbar and footer measure themselves into a Reanimated context**, and
|
|
46
|
+
every scrollable reserves exactly that. This is the whole point of the
|
|
47
|
+
component: no screen carries a hand-tuned padding number that is right on one
|
|
48
|
+
device and wrong on the next. The reserves are spacer *views* whose height is
|
|
49
|
+
an animated style, not content-container padding — padding cannot animate on
|
|
50
|
+
the UI thread, and the heights are only known after layout.
|
|
51
|
+
- **`placement`, not `variant`.** `overlay` (the default) floats the chrome over
|
|
52
|
+
the content, which insets itself to match; `static` puts it in the flow. In
|
|
53
|
+
this package `variant` always means a visual variant, so the axis that decides
|
|
54
|
+
where a part *sits* gets its own name.
|
|
55
|
+
- **Every scrollable forwards its ref through `React.forwardRef`.** `ScrollArea`
|
|
56
|
+
hands back `ScreenScrollViewRef`, `FlatList` a `FlatList<ItemT>`, `SectionList`
|
|
57
|
+
a `SectionList<ItemT, SectionT>`, `LegendList` and the chat list's `legend`
|
|
58
|
+
variant a `LegendListRef`, and the chat list's `flat` variant a `FlatList`. The
|
|
59
|
+
`*Props` types carry no `ref` member — `RefAttributes<T>` on the component type
|
|
60
|
+
carries it now — so do not add one back.
|
|
61
|
+
|
|
62
|
+
These — and the chat list's two private variants — are the **only** `forwardRef`
|
|
63
|
+
components in the package; everything else (`Input`, `Pressable`,
|
|
64
|
+
`Tabs.ScrollView`, `lib/slot`) is on React 19's ref-as-prop. The split is deliberate rather than half-finished. What it bought
|
|
65
|
+
here is that the ref is *named*: `FlatList`, `SectionList`, `LegendList` and
|
|
66
|
+
`ChatList`'s flat variant used to declare `ref?:` and never destructure it, so
|
|
67
|
+
it rode `{...props}` into the JSX — and in `LegendList` that spread is cast to
|
|
68
|
+
`AnimatedLegendListProps<ItemT>`, a type with no `ref` member, so nothing
|
|
69
|
+
checked that the ref arrived at all.
|
|
70
|
+
- **`forwardRef` erases type parameters, so every generic scrollable is cast to a
|
|
71
|
+
hand-written component type.** `forwardRef`'s result is a
|
|
72
|
+
`ForwardRefExoticComponent` over one concrete instantiation, so `data` and
|
|
73
|
+
`renderItem` would check against `unknown` and stop constraining each other.
|
|
74
|
+
`ScreenFlatListComponent` and its siblings restate the signature — the same
|
|
75
|
+
move `StyledAnimatedLegendListComponent` already makes for `withUniwind`. Each
|
|
76
|
+
carries a `displayName?: string` member, which is what keeps the trailing
|
|
77
|
+
assignment legal after the cast.
|
|
78
|
+
- **`ChatList`'s two ref types are correlated with `variant` by hand.**
|
|
79
|
+
`forwardRef<T, P>` has a single `T`, so the union arms in
|
|
80
|
+
`ScreenChatListComponent` and the two casts in the dispatcher are the only
|
|
81
|
+
thing holding `variant: "flat"` to a `FlatList<ItemT>` and `legend` to a
|
|
82
|
+
`LegendListRef`. Nothing checks them. Edit the arms and the branches together
|
|
83
|
+
or not at all.
|
|
84
|
+
- **`ChatList`'s legend variant composes its ref through `composeRefs`, memoised
|
|
85
|
+
on the caller's ref.** It needs an inner handle of its own for the
|
|
86
|
+
`scrollToEnd` fallback, so the caller's ref is fanned rather than passed
|
|
87
|
+
through. `composeRefs` mints a new callback per call and React detaches and
|
|
88
|
+
reattaches a ref whose identity changed, so calling it inline would null the
|
|
89
|
+
inner handle and re-resolve the native one on every render — with the
|
|
90
|
+
`scrollToEnd` effect firing against a ref mid-swap.
|
|
91
|
+
- **`ScrollArea` branches on `keyboardAware` rather than picking a component
|
|
92
|
+
into a variable.** A `cond ? A : B` component types its `ref` as the two
|
|
93
|
+
refs' intersection, which is neither engine's, so `forwardRef` could not name
|
|
94
|
+
a single instance type at all. The one cast in that branch is variance and not
|
|
95
|
+
a guess: `RefObject.current` is mutable and so invariant, while
|
|
96
|
+
`KeyboardAwareScrollViewRef` is genuinely `ScreenScrollViewRef` plus
|
|
97
|
+
`assureFocusedInputVisible`.
|
|
98
|
+
- **`ScreenScrollViewRef` is React Native's `ScrollView`, not
|
|
99
|
+
`ComponentRef<typeof Animated.ScrollView>`.** Reanimated's animated component
|
|
100
|
+
types carry no `RefAttributes`, so that expression collapses to `never` — and
|
|
101
|
+
`Ref<never>` still compiles at every call site, silently accepts any ref, and
|
|
102
|
+
hands the caller a `.current` with nothing on it. Both engines forward to the
|
|
103
|
+
same RN instance, so naming it directly is both truthful and usable from a
|
|
104
|
+
JS-thread `useRef` and a UI-thread `useAnimatedRef` alike.
|
|
105
|
+
|
|
106
|
+
## Reserves and occupancy
|
|
107
|
+
|
|
108
|
+
- **The footer's padding is a style, not a class**, and that is load-bearing.
|
|
109
|
+
`footerOccupancy()` has to add the same band to a height measured at runtime,
|
|
110
|
+
and a class is unreadable from JS. `SCREEN_FOOTER_PADDING` and
|
|
111
|
+
`SCREEN_FLOATING_BOTTOM_GAP` therefore stay numbers in `screen.variants.ts`
|
|
112
|
+
rather than joining the tokens in `tokens.css` — one constant drives both the
|
|
113
|
+
render and the reserve, so they cannot drift. Do not "tidy" them into
|
|
114
|
+
utilities.
|
|
115
|
+
- **Two occupancy numbers, differing by exactly the safe-area inset.**
|
|
116
|
+
`footerOccupancy` is what the footer covers in list-content space and is the
|
|
117
|
+
same in both keyboard states; `footerAboveKeyboard` is what it covers above an
|
|
118
|
+
open keyboard and excludes the band the sticky shift parks behind it.
|
|
119
|
+
Conflating them is a real bug — the derivation is in the block comment above
|
|
120
|
+
them, and the tests pin both.
|
|
121
|
+
|
|
122
|
+
## Lists
|
|
123
|
+
|
|
124
|
+
- **`Screen.ChatList` is a discriminated union on `variant`.** `legend`
|
|
125
|
+
(default) takes chronological oldest-first data and LegendList's own
|
|
126
|
+
`renderItem`; `flat` is an inverted `FlatList` with newest-first data and RN's.
|
|
127
|
+
The two `renderItem` contracts genuinely differ, so each variant exposes its
|
|
128
|
+
engine's own rather than adapting one into the other — an adapter would have to
|
|
129
|
+
fabricate the `separators` object RN's signature promises, and nothing would
|
|
130
|
+
honour it.
|
|
131
|
+
- **Seed a chat list with `composerBaseHeight`.** The footer publishes its real
|
|
132
|
+
height a commit or two after the list's first layout, and the list has already
|
|
133
|
+
scrolled to the end by then. Without the seed the newest message hides under
|
|
134
|
+
the composer — intermittently, which is the worst kind of wrong.
|
|
135
|
+
|
|
136
|
+
## Debug
|
|
137
|
+
|
|
138
|
+
- **`<Screen debug>` paints every layer.** Opt-in per screen and deliberately
|
|
139
|
+
*not* gated behind `__DEV__`, so a reserve can still be inspected on a release
|
|
140
|
+
build. The green occupancy band's edge must land on the footer's red one; a red
|
|
141
|
+
sliver past it means the reserve is short. `SCREEN_DEBUG_COLORS` is exported so
|
|
142
|
+
a composer can paint matching bands. These are raw `rgba()` literals rather
|
|
143
|
+
than theme tokens on purpose — a debug layer in a semantic colour would be
|
|
144
|
+
invisible against the surface it sits on.
|
|
145
|
+
|
|
146
|
+
## Chrome
|
|
147
|
+
|
|
148
|
+
- **Safe-area insets come from `useSafeAreaInsets()`, never from uniwind's
|
|
149
|
+
`*-safe` utilities.** `pt-safe` and friends compile to
|
|
150
|
+
`env(safe-area-inset-top)`, which resolves to **zero** on React Native — the
|
|
151
|
+
class applies, nothing moves, and a navbar draws over the status bar with no
|
|
152
|
+
error anywhere. Verified on a simulator, not assumed. The hook is also the
|
|
153
|
+
source the footer's occupancy maths already reads, so a container's inset and
|
|
154
|
+
the reserve computed against it cannot disagree. `resolveScreenEdgePadding`
|
|
155
|
+
turns an `insets` prop into a style object.
|
|
156
|
+
- **A `static` footer is opaque; an `overlay` footer is not.** The backing lives
|
|
157
|
+
INSIDE the sticky view so it travels with the keyboard translation — put it on
|
|
158
|
+
the positioned outer view instead and a static footer lifted over the content
|
|
159
|
+
lets that content show straight through it, its buttons legible only where
|
|
160
|
+
they happen to overlap blank space. An overlay footer stays transparent on
|
|
161
|
+
purpose: content is meant to scroll under it, and whatever the caller puts
|
|
162
|
+
inside brings its own surface.
|
|
163
|
+
- **Both hairlines are drawn at rest**, and `fadeBorderOnScroll` — the same prop
|
|
164
|
+
name on `Screen.Navbar` and `Screen.Footer` — opts into a scroll-linked ramp
|
|
165
|
+
instead. The two read **opposite ends of the scroll**: the navbar's answers
|
|
166
|
+
"is there content above?" and brightens as you leave the top, while the
|
|
167
|
+
footer's answers "is there content below?" and fades out as the content runs
|
|
168
|
+
out. A footer line driven by `scrollY` would be brightest exactly where there
|
|
169
|
+
is nothing left to scroll to. One `SCREEN_BORDER_FADE_DISTANCE` drives both,
|
|
170
|
+
because two numbers that should always agree are two numbers that can drift.
|
|
171
|
+
- **A worklet cannot rely on a module-scope helper.** `resolveNavbarBorderOpacity`
|
|
172
|
+
and `resolveFooterBorderOpacity` write their `Math.min(1, Math.max(0, …))`
|
|
173
|
+
clamp out separately rather than sharing one. Factoring it into a `clampUnit`
|
|
174
|
+
helper — itself marked `"worklet"` — crashed the UI thread with `undefined is
|
|
175
|
+
not a function`: it was not captured into the worklet's closure. No unit test
|
|
176
|
+
sees it, because on the JS thread the helper resolves perfectly. Keep a
|
|
177
|
+
worklet's body self-contained.
|
|
178
|
+
That default is the way round it is because a screen whose content starts
|
|
179
|
+
flush against the chrome wants the line from the first frame — with the fade
|
|
180
|
+
always on, screens re-added a border by hand, which is the signal the default
|
|
181
|
+
was wrong. Both ramps are written out rather than calling Reanimated's
|
|
182
|
+
`interpolate`, so they stay reachable from `bun test`, and both clamp at each
|
|
183
|
+
end because a rubber-banded overscroll reports a negative offset.
|
|
184
|
+
|
|
185
|
+
## ScrollShadow
|
|
186
|
+
|
|
187
|
+
A scrollable with a hard edge looks finished. A row cut exactly in half by the
|
|
188
|
+
bottom of the viewport reads as a layout bug; the same row fading out reads as
|
|
189
|
+
more to come. `Screen.ScrollShadow` draws that fade, and it matters most under
|
|
190
|
+
chrome that floats over the content, where the cut lands mid-component and there
|
|
191
|
+
is no bar edge to explain it.
|
|
192
|
+
|
|
193
|
+
**It is a sibling of the body, never a wrapper around it.** The screen context
|
|
194
|
+
already publishes `scrollY`, `contentHeight` and `layoutHeight` from whichever
|
|
195
|
+
scrollable is mounted, so this reads the numbers instead of intercepting them.
|
|
196
|
+
Wrapping would mean cloning the child to attach an `onScroll`, and every
|
|
197
|
+
scrollable here already has one — `use-screen-scroll-insets` drives the reserve
|
|
198
|
+
animations from it — so a second writer would take a handler already spoken for.
|
|
199
|
+
The dividend is that one component works over `ScrollArea`, `FlatList`,
|
|
200
|
+
`SectionList`, `LegendList` and `ChatList` alike, because they all publish to the
|
|
201
|
+
same context.
|
|
202
|
+
|
|
203
|
+
**It places itself against the chrome.** The top fade starts at the navbar's
|
|
204
|
+
measured height and the bottom above the footer's, so it is correct under either
|
|
205
|
+
placement with the caller measuring nothing. A fade at zero would sit behind the
|
|
206
|
+
navbar and be visible nowhere, which is a component that silently does nothing.
|
|
207
|
+
|
|
208
|
+
**`coverTop` extends the band, it does not move it.** For chrome this package
|
|
209
|
+
cannot see — a bar floating between the navbar and the body — the content should
|
|
210
|
+
already be gone where it passes behind, so those points are painted solid and the
|
|
211
|
+
gradient begins below them. Offsetting the fade past the chrome instead leaves
|
|
212
|
+
content crisp behind whatever parts of it are transparent, then cuts abruptly
|
|
213
|
+
where the fade finally starts: a floating pill is mostly transparent row, so rows
|
|
214
|
+
slid behind it at full contrast and were chopped underneath. `coverBottom` is the
|
|
215
|
+
same for chrome at the end.
|
|
216
|
+
|
|
217
|
+
**`anchor` decides what it measures from, and chrome drawing this behind itself
|
|
218
|
+
needs `parent`.** Paint order is source order: a fade mounted as a sibling of the
|
|
219
|
+
body lands on top of anything mounted after it, so chrome that floats over the
|
|
220
|
+
content — a tab bar inside a navigator, where nothing outside can slot between
|
|
221
|
+
the pages and the bar — has to render the fade as its own first child and anchor
|
|
222
|
+
it to that container rather than to the screen.
|
|
223
|
+
|
|
224
|
+
**Each end fades only when there is something past it.** The top is out at rest
|
|
225
|
+
and arrives over the first `size` points; the bottom is there from the start and
|
|
226
|
+
leaves over the last. Content shorter than its viewport never scrolls, so
|
|
227
|
+
`maxScroll` is zero and both stay out — a fade over a short page promises content
|
|
228
|
+
that does not exist.
|
|
229
|
+
|
|
230
|
+
**The gradient is `expo-linear-gradient`, and it is the one hard peer this
|
|
231
|
+
component adds.** `screen.tsx` imports it through the compound, so every app that
|
|
232
|
+
touches `Screen` resolves it whether or not it draws a fade — that is the price
|
|
233
|
+
of a native gradient view over drawing one in SVG, and it is paid by consumers
|
|
234
|
+
rather than by this package.
|
|
235
|
+
|
|
236
|
+
**The far stop is the near colour at zero alpha, never `transparent`.** The
|
|
237
|
+
keyword is transparent *black*, so interpolating toward it drags every stop
|
|
238
|
+
between through grey — a dark bloom over a light ground, a milky one over a
|
|
239
|
+
dark. `transparentOf` in `lib/color.ts` takes the alpha off instead, and returns
|
|
240
|
+
`undefined` for a notation it cannot take apart; the component then declines to
|
|
241
|
+
draw rather than reaching for the keyword after all.
|
|
242
|
+
|
|
243
|
+
**Opacity ramps over a quarter of `size`, not all of it.** A fade that takes its
|
|
244
|
+
own height to arrive reads as lagging the finger; a quarter puts it there while
|
|
245
|
+
the first row is still leaving.
|
|
246
|
+
|
|
247
|
+
## API
|
|
248
|
+
|
|
249
|
+
- **There is no `Navbar.Action`.** [`Button`](../button/AGENTS.md) already owns that vocabulary —
|
|
250
|
+
loading swap, icon inheritance, haptics, accessibility — so a navbar action is
|
|
251
|
+
written as `<Button size="icon-sm" variant="secondary">`. A second
|
|
252
|
+
definition of props a component does not change is one that can drift.
|
|
253
|
+
- **`Screen.Navbar.BackButton` takes an `onPress`.** This library has no
|
|
254
|
+
navigation dependency and must not gain one for a chevron. The app wires
|
|
255
|
+
`router.back()`. `Screen.Footer`'s `isFocused` prop is the same trade: an app
|
|
256
|
+
with a router passes `useIsFocused()`, and without one the footer behaves as
|
|
257
|
+
focused.
|
|
258
|
+
|
|
259
|
+
## Structure
|
|
260
|
+
|
|
261
|
+
- **The root lives in `screen-root.tsx`, not `screen.tsx`.** `Screen.Loading`
|
|
262
|
+
and `Screen.Error` are whole screens — they render the root — so putting it in
|
|
263
|
+
the file that runs the `Object.assign` naming them would close a cycle. This is
|
|
264
|
+
rule 3 applied inside a folder.
|
|
265
|
+
- **A nested `<Screen>` passes the outer context through** rather than shadowing
|
|
266
|
+
it, so `Screen.Loading` returned from inside a screen does not start a second,
|
|
267
|
+
unread set of measurements.
|
|
268
|
+
|
|
269
|
+
## Dependencies
|
|
270
|
+
|
|
271
|
+
- **One optional peer, and one hard one.** `@legendapp/list` is
|
|
272
|
+
`peerDependenciesMeta.optional`, so an app that never imports `Screen` never
|
|
273
|
+
resolves it. `expo-linear-gradient` is not optional: `Screen.ScrollShadow`
|
|
274
|
+
imports it at module scope and the compound imports that, so every consumer of
|
|
275
|
+
`Screen` needs it installed.
|
|
276
|
+
`react-native-keyboard-controller` used to be the second — see
|
|
277
|
+
[DelacourProvider](../provider/AGENTS.md) for why it stopped.
|
|
278
|
+
- **The app must mount `DelacourProvider` at its root.** It supplies the gesture
|
|
279
|
+
root, `SafeAreaProvider`, `KeyboardProvider` and — the one that is not optional
|
|
280
|
+
polish — `<KeyboardStateSync />` inside the keyboard provider.
|
|
281
|
+
`KeyboardProvider` owns exactly one pair of shared animation values for the
|
|
282
|
+
whole app, and on iOS they are written only from the `will` events. Any
|
|
283
|
+
teardown that skips one — an interactive dismiss interrupted by navigation, a
|
|
284
|
+
stack pop, an app suspend — pins them open app-wide, and every screen then
|
|
285
|
+
renders "keyboard open" over a keyboard that is not there. `Screen.Footer` runs
|
|
286
|
+
the same repair on mount as a backstop.
|
|
287
|
+
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export { Screen } from "./screen";
|
|
2
|
+
export {
|
|
3
|
+
type ScreenContextValue,
|
|
4
|
+
type ScreenFooterMeasurements,
|
|
5
|
+
type ScreenNavbarMeasurements,
|
|
6
|
+
ScreenProvider,
|
|
7
|
+
type ScreenProviderProps,
|
|
8
|
+
useScreen,
|
|
9
|
+
useScreenContext,
|
|
10
|
+
useScreenDebug,
|
|
11
|
+
} from "./screen.context";
|
|
12
|
+
export type {
|
|
13
|
+
ScreenInsetProps,
|
|
14
|
+
ScreenPlacementProps,
|
|
15
|
+
ScreenScrollableProps,
|
|
16
|
+
ScreenScrollViewRef,
|
|
17
|
+
} from "./screen.types";
|
|
18
|
+
export {
|
|
19
|
+
CHAT_COMPOSER_GAP,
|
|
20
|
+
footerAboveKeyboard,
|
|
21
|
+
footerOccupancy,
|
|
22
|
+
resolveFooterBorderOpacity,
|
|
23
|
+
resolveNavbarBorderOpacity,
|
|
24
|
+
resolveScreenEdgePadding,
|
|
25
|
+
resolveScreenViewPadding,
|
|
26
|
+
resolveScrollBottomInset,
|
|
27
|
+
resolveScrollTopInset,
|
|
28
|
+
SCREEN_BORDER_FADE_DISTANCE,
|
|
29
|
+
SCREEN_EDGES,
|
|
30
|
+
SCREEN_FLOATING_BOTTOM_GAP,
|
|
31
|
+
SCREEN_FOOTER_PADDING,
|
|
32
|
+
SCREEN_PLACEMENTS,
|
|
33
|
+
SCREEN_SCROLL_INSET_MODES,
|
|
34
|
+
type ScreenEdge,
|
|
35
|
+
type ScreenEdgeInsets,
|
|
36
|
+
type ScreenEdgePadding,
|
|
37
|
+
type ScreenPlacement,
|
|
38
|
+
type ScreenVariantProps,
|
|
39
|
+
screenVariants,
|
|
40
|
+
} from "./screen.variants";
|
|
41
|
+
export type {
|
|
42
|
+
LegendListRef,
|
|
43
|
+
ScreenChatListFlatProps,
|
|
44
|
+
ScreenChatListLegendProps,
|
|
45
|
+
ScreenChatListProps,
|
|
46
|
+
} from "./screen-chat-list";
|
|
47
|
+
export { SCREEN_CHAT_INPUT_NATIVE_ID, type ScreenContentProps } from "./screen-content";
|
|
48
|
+
export { SCREEN_DEBUG_COLORS, type ScreenDebugLayer } from "./screen-debug";
|
|
49
|
+
export type { ScreenErrorProps } from "./screen-error";
|
|
50
|
+
export type { ScreenFlatListProps } from "./screen-flat-list";
|
|
51
|
+
export type { ScreenFooterProps } from "./screen-footer";
|
|
52
|
+
export type { ScreenFooterBackgroundProps } from "./screen-footer-background";
|
|
53
|
+
export type { ScreenHeaderProps } from "./screen-header";
|
|
54
|
+
export type { ScreenLegendListProps } from "./screen-legend-list";
|
|
55
|
+
export type { ScreenLoadingProps } from "./screen-loading";
|
|
56
|
+
export type { ScreenNavbarProps } from "./screen-navbar";
|
|
57
|
+
export {
|
|
58
|
+
SCREEN_BACK_BUTTON_GLYPHS,
|
|
59
|
+
type ScreenBackButtonGlyph,
|
|
60
|
+
type ScreenNavbarBackButtonProps,
|
|
61
|
+
} from "./screen-navbar-back-button";
|
|
62
|
+
export type { ScreenNavbarBackgroundProps } from "./screen-navbar-background";
|
|
63
|
+
export type { ScreenNavbarSubtitleProps } from "./screen-navbar-subtitle";
|
|
64
|
+
export type { ScreenNavbarTitleProps } from "./screen-navbar-title";
|
|
65
|
+
export type { ScreenRootProps } from "./screen-root";
|
|
66
|
+
export type { ScreenScrollAreaProps } from "./screen-scroll-area";
|
|
67
|
+
export {
|
|
68
|
+
SCROLL_SHADOW_EDGES,
|
|
69
|
+
ScreenScrollShadow,
|
|
70
|
+
type ScreenScrollShadowEdge,
|
|
71
|
+
type ScreenScrollShadowProps,
|
|
72
|
+
} from "./screen-scroll-shadow";
|
|
73
|
+
export type { ScreenSectionListProps } from "./screen-section-list";
|
|
74
|
+
export type { ScreenViewProps } from "./screen-view";
|
|
75
|
+
export {
|
|
76
|
+
type ChatComposerSpacer,
|
|
77
|
+
type ScreenScrollInsetMode,
|
|
78
|
+
type ScreenScrollInsets,
|
|
79
|
+
useChatComposerBaseSpacerHeight,
|
|
80
|
+
useChatComposerGrowthPadding,
|
|
81
|
+
useChatComposerInset,
|
|
82
|
+
useScreenFooterKeyboardClearance,
|
|
83
|
+
useScreenFooterOverlayHeight,
|
|
84
|
+
useScreenScrollInsets,
|
|
85
|
+
} from "./use-screen-scroll-insets";
|