@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,670 @@
|
|
|
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 bar is painted. `primary` is a capsule in a track, `secondary` an underline. */
|
|
6
|
+
export const TABS_VARIANTS = ["primary", "secondary"] as const;
|
|
7
|
+
|
|
8
|
+
export const TABS_SIZES = ["sm", "md", "lg"] as const;
|
|
9
|
+
|
|
10
|
+
/** Where a `Tabs.ScrollView` puts the selected trigger. `none` leaves the bar alone. */
|
|
11
|
+
export const TABS_SCROLL_ALIGNS = ["start", "center", "end", "none"] as const;
|
|
12
|
+
|
|
13
|
+
export type TabsVariant = (typeof TABS_VARIANTS)[number];
|
|
14
|
+
export type TabsSize = (typeof TABS_SIZES)[number];
|
|
15
|
+
export type TabsScrollAlign = (typeof TABS_SCROLL_ALIGNS)[number];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The axes the bar falls back to when nothing names one.
|
|
19
|
+
*
|
|
20
|
+
* Named once because two places read them — `defaultVariants` below, and the
|
|
21
|
+
* resolvers, which run before `tv` is ever called. A test pins the pair, since a
|
|
22
|
+
* drift between them is a bar that renders at one size and reports another.
|
|
23
|
+
*/
|
|
24
|
+
export const TABS_DEFAULT_VARIANT: TabsVariant = "primary";
|
|
25
|
+
export const TABS_DEFAULT_SIZE: TabsSize = "md";
|
|
26
|
+
export const TABS_DEFAULT_SCROLL_ALIGN: TabsScrollAlign = "center";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The `Text` size step each bar size hands its label.
|
|
30
|
+
*
|
|
31
|
+
* `Tabs.Label` renders `Text.Label` and names this step rather than restating a
|
|
32
|
+
* type scale in the `label` slot, so the weight stays in exactly one place — the
|
|
33
|
+
* preset — while the label still tracks the bar's size. A test asserts every
|
|
34
|
+
* value here is a size `Text` actually has.
|
|
35
|
+
*/
|
|
36
|
+
export const TABS_LABEL_TEXT_SIZE: Record<TabsSize, TextSize> = { sm: "sm", md: "md", lg: "lg" };
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The theme token a trigger's label and any glyph beside it take.
|
|
40
|
+
*
|
|
41
|
+
* Nested rather than flat, so adding a variant is a compile error in two places
|
|
42
|
+
* instead of a silent gap in one.
|
|
43
|
+
*
|
|
44
|
+
* This is the **only** place either colour is named. The label crossfades between
|
|
45
|
+
* them on the UI thread and a composed `Icon` takes one of them as a resolved
|
|
46
|
+
* value, and neither of those can be a class — so the `label` slot carries no
|
|
47
|
+
* colour at all and this map is what `bun test` sweeps instead. `Checkbox`'s
|
|
48
|
+
* animated border makes the same trade for the same reason.
|
|
49
|
+
*/
|
|
50
|
+
export const TABS_FOREGROUND_TOKEN: Record<TabsVariant, { selected: string; unselected: string }> = {
|
|
51
|
+
primary: { selected: "elevated-foreground", unselected: "muted-foreground" },
|
|
52
|
+
secondary: { selected: "foreground", unselected: "muted-foreground" },
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* There is no radius arithmetic here, and its absence is the design.
|
|
57
|
+
*
|
|
58
|
+
* The track and the capsule are both `rounded-full`, so the capsule is
|
|
59
|
+
* concentric inside the track at *any* padding — a pill inside a pill always is.
|
|
60
|
+
* That is what `Checkbox`'s "radius minus border width" rule exists to achieve
|
|
61
|
+
* for a rounded rectangle, and a fully rounded pair gets it for free.
|
|
62
|
+
*
|
|
63
|
+
* The earlier shape subtracted a padding from a named `rounded-*` step at each
|
|
64
|
+
* size, which meant three maps that had to stay in step and a rectangle that
|
|
65
|
+
* still read as boxy against its own track. Do not reintroduce them.
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The spring the indicator and the panels settle on after a tap or a released pan.
|
|
70
|
+
*
|
|
71
|
+
* One spring for both, because they are two readings of one shared value and a
|
|
72
|
+
* second config would be a second clock they could drift apart on. Deliberately
|
|
73
|
+
* without `clamp`: Reanimated's `SpringConfig` is a mutually exclusive union and
|
|
74
|
+
* `clamp` belongs to the `duration`/`dampingRatio` branch, so pairing it with
|
|
75
|
+
* `stiffness` is a type error. A test pins that it stays absent.
|
|
76
|
+
*/
|
|
77
|
+
export const TABS_SETTLE_SPRING = { damping: 20, mass: 0.5, stiffness: 260 } as const;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The pan's thresholds.
|
|
81
|
+
*
|
|
82
|
+
* `activateX` is how far a drag travels sideways before the pager claims it;
|
|
83
|
+
* `failY` is how far it travels vertically before the pager gives up and lets the
|
|
84
|
+
* scrollable above it through. `activateX` is the smaller of the two on purpose —
|
|
85
|
+
* a diagonal drag has to resolve to exactly one of them, and the pager is the one
|
|
86
|
+
* that cannot be reached any other way.
|
|
87
|
+
*
|
|
88
|
+
* `flingVelocity` is in **index units per second**, not points per second. In
|
|
89
|
+
* points it would mean a different flick on a phone than on a tablet, because a
|
|
90
|
+
* page is the width of the screen.
|
|
91
|
+
*
|
|
92
|
+
* `overscroll` bounds how far past the first or last tab a drag can pull, in the
|
|
93
|
+
* same index units.
|
|
94
|
+
*/
|
|
95
|
+
export const TABS_PAN = { activateX: 10, failY: 14, flingVelocity: 0.5, overscroll: 0.25 } as const;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* How far past the midpoint the pager travels before the labels swap treatment.
|
|
99
|
+
*
|
|
100
|
+
* A finger held at exactly the midpoint would otherwise flip the label back and
|
|
101
|
+
* forth every frame, and each flip costs a React commit. Must stay under 0.5, or
|
|
102
|
+
* the visual index can never change at all.
|
|
103
|
+
*/
|
|
104
|
+
export const TABS_VISUAL_HYSTERESIS = 0.08;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The ramp a separator fades on, in index units either side of the gap it fills.
|
|
108
|
+
*
|
|
109
|
+
* `hold` keeps it fully out for a little way either side of dead centre, so the
|
|
110
|
+
* rule is properly gone while the capsule is over it rather than flickering
|
|
111
|
+
* through a thin band.
|
|
112
|
+
*/
|
|
113
|
+
export const TABS_SEPARATOR_FADE = { distance: 0.45, hold: 0.1 } as const;
|
|
114
|
+
|
|
115
|
+
/** Points of the neighbouring trigger left visible by `scrollAlign` `start` and `end`. */
|
|
116
|
+
export const TABS_SCROLL_INSET = 16;
|
|
117
|
+
|
|
118
|
+
/** One trigger's frame in the row's own coordinate space. */
|
|
119
|
+
export type TabMeasurement = { x: number; width: number };
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The three arrays `interpolate` needs, built once per layout rather than per frame.
|
|
123
|
+
*
|
|
124
|
+
* Replaced as ONE value so their lengths can never disagree — `interpolate` throws
|
|
125
|
+
* when the input and output ranges differ, and three separate shared values would
|
|
126
|
+
* be three separate chances for the UI thread to read a half-updated set.
|
|
127
|
+
*/
|
|
128
|
+
export type TabTracks = { index: number[]; width: number[]; x: number[] };
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The list `position` indexes into.
|
|
132
|
+
*
|
|
133
|
+
* Panels first, because their order is known at the FIRST render — a registered
|
|
134
|
+
* order only settles after every trigger's mount effect has run, which is one
|
|
135
|
+
* commit late, and one commit of the wrong order is an indicator that visibly
|
|
136
|
+
* lands on the wrong tab and then corrects itself.
|
|
137
|
+
*
|
|
138
|
+
* Registration is the fallback for a bar with no panels at all, which is a real
|
|
139
|
+
* shape: a filter row driving a list somewhere else. The two can never disagree,
|
|
140
|
+
* because the second is only consulted when the first is empty.
|
|
141
|
+
*/
|
|
142
|
+
export function resolveTabOrder(contentValues: readonly string[], registeredValues: readonly string[]): string[] {
|
|
143
|
+
return contentValues.length > 0 ? [...contentValues] : [...registeredValues];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Where a value sits in the order, or `-1` when nothing claims it.
|
|
148
|
+
*
|
|
149
|
+
* `-1` rather than 0 so a `value` naming a tab that does not exist is detectable
|
|
150
|
+
* instead of silently selecting the first one.
|
|
151
|
+
*/
|
|
152
|
+
export function resolveTabIndex(order: readonly string[], value: string | null | undefined): number {
|
|
153
|
+
if (value === null || value === undefined) return -1;
|
|
154
|
+
return order.indexOf(value);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* What an uncontrolled `Tabs` starts on.
|
|
159
|
+
*
|
|
160
|
+
* The first tab, so a bar is never mounted with nothing selected and an indicator
|
|
161
|
+
* with nowhere to sit.
|
|
162
|
+
*
|
|
163
|
+
* An explicit `null` is a value rather than an absence, so `defaultValue={null}`
|
|
164
|
+
* starts the bar with nothing selected — the rule `pressedScale` already follows.
|
|
165
|
+
* Only an omitted default falls through to the first tab.
|
|
166
|
+
*/
|
|
167
|
+
export function resolveInitialValue(order: readonly string[], defaultValue: string | null | undefined): string | null {
|
|
168
|
+
if (defaultValue !== undefined) return defaultValue;
|
|
169
|
+
return order[0] ?? null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Whether a selection is a real change. A re-press of the current tab is not. */
|
|
173
|
+
export function shouldEmitTabChange(current: string | null, next: string): boolean {
|
|
174
|
+
return current !== next;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The interpolation tracks, or `null` while any tab is still unmeasured.
|
|
179
|
+
*
|
|
180
|
+
* All-or-nothing on purpose. A partially measured bar would put an `x` of 0 and a
|
|
181
|
+
* `width` of 0 in the middle of the track, and the indicator would collapse to
|
|
182
|
+
* nothing every time `position` crossed that tab — visible for exactly the frame
|
|
183
|
+
* or two before the missing trigger lays out, which is the hardest kind of bug to
|
|
184
|
+
* catch on a simulator. `null` means "draw nothing yet".
|
|
185
|
+
*
|
|
186
|
+
* A single tab is padded to a two-point range: `interpolate` needs at least two,
|
|
187
|
+
* and handling that in the worklet instead would put a branch on the hot path for
|
|
188
|
+
* a case that is settled once, here, at layout time.
|
|
189
|
+
*/
|
|
190
|
+
export function resolveMeasurementTracks(
|
|
191
|
+
order: readonly string[],
|
|
192
|
+
measured: Readonly<Record<string, TabMeasurement>>
|
|
193
|
+
): TabTracks | null {
|
|
194
|
+
if (order.length === 0) return null;
|
|
195
|
+
|
|
196
|
+
const first = measured[order[0]];
|
|
197
|
+
if (!first) return null;
|
|
198
|
+
|
|
199
|
+
if (order.length === 1) {
|
|
200
|
+
return { index: [0, 1], width: [first.width, first.width], x: [first.x, first.x] };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const index: number[] = [];
|
|
204
|
+
const width: number[] = [];
|
|
205
|
+
const x: number[] = [];
|
|
206
|
+
|
|
207
|
+
for (let i = 0; i < order.length; i++) {
|
|
208
|
+
const measurement = measured[order[i]];
|
|
209
|
+
if (!measurement) return null;
|
|
210
|
+
index.push(i);
|
|
211
|
+
width.push(measurement.width);
|
|
212
|
+
x.push(measurement.x);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { index, width, x };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Whether the measured triggers sit left to right in the order the panels do.
|
|
220
|
+
*
|
|
221
|
+
* A caller who writes `Tabs.Content` in one order and `Tabs.Trigger` in another
|
|
222
|
+
* gets an indicator that lands on the wrong tab, with nothing in the source to
|
|
223
|
+
* point at. Every trigger sharing an `x` of 0 catches the other shape — a trigger
|
|
224
|
+
* wrapped in a `View`, whose `onLayout` reports coordinates relative to that
|
|
225
|
+
* wrapper rather than to the row.
|
|
226
|
+
*
|
|
227
|
+
* Development only; the root warns by name. This is `Radio`'s move for a
|
|
228
|
+
* convention no type can express: check it where it *is* expressible.
|
|
229
|
+
*/
|
|
230
|
+
export function isTriggerOrderConsistent(xs: readonly number[]): boolean {
|
|
231
|
+
for (let i = 1; i < xs.length; i++) {
|
|
232
|
+
if (xs[i] <= xs[i - 1]) return false;
|
|
233
|
+
}
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** How `position` should be brought back in line with React's selection. */
|
|
238
|
+
export type TabsReconcileMode = "jump" | "none" | "spring";
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Which repair a commit needs, if any.
|
|
242
|
+
*
|
|
243
|
+
* `jump` is the case a float index alone cannot see: the selected TAB did not
|
|
244
|
+
* change, but the list did, so its index moved under it. Springing there would
|
|
245
|
+
* slide the pager sideways for an edit the user did not make.
|
|
246
|
+
*
|
|
247
|
+
* `none` covers both the settled case and a controlled parent rejecting a change —
|
|
248
|
+
* a rejected press must not animate.
|
|
249
|
+
*/
|
|
250
|
+
export function resolveReconcileMode(state: {
|
|
251
|
+
selectedIndex: number;
|
|
252
|
+
selectedValue: string | null;
|
|
253
|
+
targetIndex: number;
|
|
254
|
+
targetValue: string | null;
|
|
255
|
+
}): TabsReconcileMode {
|
|
256
|
+
if (state.selectedIndex < 0) return "none";
|
|
257
|
+
if (state.targetIndex === state.selectedIndex) return "none";
|
|
258
|
+
if (state.targetValue !== null && state.targetValue === state.selectedValue) return "jump";
|
|
259
|
+
return "spring";
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* What a panel tells assistive technology.
|
|
264
|
+
*
|
|
265
|
+
* Every panel is mounted, so an unselected one is content a screen reader would
|
|
266
|
+
* otherwise read and a finger could reach. The two platform props are one
|
|
267
|
+
* decision, so they live in one place rather than being written out at the call
|
|
268
|
+
* site where only one of them can be forgotten.
|
|
269
|
+
*/
|
|
270
|
+
export function resolveContentAccessibility(isSelected: boolean): {
|
|
271
|
+
accessibilityElementsHidden: boolean;
|
|
272
|
+
importantForAccessibility: "auto" | "no-hide-descendants";
|
|
273
|
+
} {
|
|
274
|
+
return isSelected
|
|
275
|
+
? { accessibilityElementsHidden: false, importantForAccessibility: "auto" }
|
|
276
|
+
: { accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants" };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The indices a separator sits between, `-1` for a value no tab claims. */
|
|
280
|
+
export function resolveSeparatorIndices(
|
|
281
|
+
order: readonly string[],
|
|
282
|
+
betweenValues: readonly [string, string]
|
|
283
|
+
): { left: number; right: number } {
|
|
284
|
+
return { left: resolveTabIndex(order, betweenValues[0]), right: resolveTabIndex(order, betweenValues[1]) };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* A trigger's settled availability.
|
|
289
|
+
*
|
|
290
|
+
* `own ?? root ?? default` — `Checkbox`'s ladder, deliberately not
|
|
291
|
+
* `Radio.Group`'s. A radio group puts itself first because escaping a disabled
|
|
292
|
+
* form group is a bug; a tab bar's disabled state is transient chrome and the
|
|
293
|
+
* per-trigger case is the common one, so it has to survive the bar-wide one being
|
|
294
|
+
* set. `??` throughout and never `||`, so `isDisabled={false}` opts one trigger
|
|
295
|
+
* out of a disabled bar.
|
|
296
|
+
*/
|
|
297
|
+
export function resolveTabsTriggerState(state: { own?: boolean; root?: boolean }): { isDisabled: boolean } {
|
|
298
|
+
return { isDisabled: state.own ?? state.root ?? false };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* The pager row's offset for a given float index.
|
|
303
|
+
*
|
|
304
|
+
* Named rather than written inline because the sign convention lives in exactly
|
|
305
|
+
* one place. Advancing to the next tab moves the row LEFT, so a positive
|
|
306
|
+
* `position` is a negative translation — getting that backwards is the single
|
|
307
|
+
* most likely bug in this component, and a test pins it.
|
|
308
|
+
*/
|
|
309
|
+
export function resolvePagerTranslate(position: number, pageWidth: number): number {
|
|
310
|
+
"worklet";
|
|
311
|
+
return -position * pageWidth;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The index the drag started from, back-computed at the moment the pan activates.
|
|
316
|
+
*
|
|
317
|
+
* `translationX` is measured from **touch-down**, but a pan does not activate
|
|
318
|
+
* until the finger has travelled `TABS_PAN.activateX`. Capturing the origin at
|
|
319
|
+
* activation without accounting for that would apply the pre-activation travel
|
|
320
|
+
* twice and jump the pager sideways on the first frame of every drag.
|
|
321
|
+
*
|
|
322
|
+
* The origin is therefore whatever value makes {@link resolvePanPosition} return
|
|
323
|
+
* the pager's *current* position for the translation seen so far — so the drag
|
|
324
|
+
* starts exactly where the pager already is, mid-spring or at rest.
|
|
325
|
+
*
|
|
326
|
+
* Self-contained, for the reason {@link resolvePanPosition} gives.
|
|
327
|
+
*/
|
|
328
|
+
export function resolvePanOrigin(position: number, translationX: number, pageWidth: number): number {
|
|
329
|
+
"worklet";
|
|
330
|
+
if (pageWidth <= 0) return position;
|
|
331
|
+
return position + translationX / pageWidth;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* `position` while a finger is down.
|
|
336
|
+
*
|
|
337
|
+
* Past either end the drag meets resistance rather than a wall — UIScrollView's
|
|
338
|
+
* own rubber band, expressed in index units, where the page width IS one unit so
|
|
339
|
+
* the formula's width term falls out. A hard clamp was the alternative and it
|
|
340
|
+
* reads as a bug: the panel stops dead under a finger that is still moving, which
|
|
341
|
+
* every user reads as a dropped gesture rather than as an edge.
|
|
342
|
+
*
|
|
343
|
+
* Self-contained, and the clamp is written out rather than shared. A module-scope
|
|
344
|
+
* worklet calling another one binds its closure at import time, in source order,
|
|
345
|
+
* and ends up `undefined` on the UI thread — a crash no unit test sees, because
|
|
346
|
+
* the JS thread resolves it perfectly. See AGENTS.md.
|
|
347
|
+
*/
|
|
348
|
+
export function resolvePanPosition(state: {
|
|
349
|
+
count: number;
|
|
350
|
+
pageWidth: number;
|
|
351
|
+
startPosition: number;
|
|
352
|
+
translationX: number;
|
|
353
|
+
}): number {
|
|
354
|
+
"worklet";
|
|
355
|
+
if (state.pageWidth <= 0 || state.count <= 0) return state.startPosition;
|
|
356
|
+
|
|
357
|
+
const raw = state.startPosition - state.translationX / state.pageWidth;
|
|
358
|
+
const max = state.count - 1;
|
|
359
|
+
const give = TABS_PAN.overscroll;
|
|
360
|
+
|
|
361
|
+
if (raw < 0) return -(1 - 1 / (-raw / give + 1)) * give;
|
|
362
|
+
if (raw > max) return max + (1 - 1 / ((raw - max) / give + 1)) * give;
|
|
363
|
+
return raw;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Which tab the pager settles on when the finger lifts.
|
|
368
|
+
*
|
|
369
|
+
* Two rules, in order. A fling — anything past `TABS_PAN.flingVelocity` — carries
|
|
370
|
+
* to the next tab in the direction of travel however little of the page it
|
|
371
|
+
* covered, because a flick is an instruction rather than a drag, and it never
|
|
372
|
+
* carries further than one tab however hard it is thrown. Otherwise the nearest
|
|
373
|
+
* tab wins, which is `Math.round`.
|
|
374
|
+
*
|
|
375
|
+
* A disabled tab is stepped over rather than landed on, walking outward in the
|
|
376
|
+
* direction of travel first and then back the other way. It stays in the ORDER —
|
|
377
|
+
* its panel still exists and still holds an index — because pulling it out would
|
|
378
|
+
* make every index depend on a state that can change at runtime.
|
|
379
|
+
*/
|
|
380
|
+
export function resolveSettleIndex(state: {
|
|
381
|
+
count: number;
|
|
382
|
+
isEnabled?: readonly boolean[];
|
|
383
|
+
position: number;
|
|
384
|
+
startIndex: number;
|
|
385
|
+
velocity: number;
|
|
386
|
+
}): number {
|
|
387
|
+
"worklet";
|
|
388
|
+
const max = state.count - 1;
|
|
389
|
+
if (max < 0) return 0;
|
|
390
|
+
|
|
391
|
+
let target = Math.round(state.position);
|
|
392
|
+
|
|
393
|
+
if (state.velocity >= TABS_PAN.flingVelocity) {
|
|
394
|
+
target = Math.max(Math.ceil(state.position), state.startIndex + 1);
|
|
395
|
+
} else if (state.velocity <= -TABS_PAN.flingVelocity) {
|
|
396
|
+
target = Math.min(Math.floor(state.position), state.startIndex - 1);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (target < 0) target = 0;
|
|
400
|
+
if (target > max) target = max;
|
|
401
|
+
|
|
402
|
+
const enabled = state.isEnabled;
|
|
403
|
+
if (enabled?.[target] !== false) return target;
|
|
404
|
+
|
|
405
|
+
// Outward from the tab the drag actually reached, trying the direction of
|
|
406
|
+
// travel first so a forward swipe keeps going forward. Both candidates are
|
|
407
|
+
// bounds-checked: past either end `enabled[i]` is `undefined`, which is not
|
|
408
|
+
// `false`, so an unguarded read would settle on a tab that does not exist.
|
|
409
|
+
const ahead = target >= state.startIndex ? 1 : -1;
|
|
410
|
+
for (let offset = 1; offset <= max; offset++) {
|
|
411
|
+
const forward = target + offset * ahead;
|
|
412
|
+
if (forward >= 0 && forward <= max && enabled[forward] !== false) return forward;
|
|
413
|
+
const backward = target - offset * ahead;
|
|
414
|
+
if (backward >= 0 && backward <= max && enabled[backward] !== false) return backward;
|
|
415
|
+
}
|
|
416
|
+
return state.startIndex;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* How selected one tab is right now, 0 to 1, from where the pager sits.
|
|
421
|
+
*
|
|
422
|
+
* `1` when the pager is squarely on this tab, `0` once it is a whole tab away,
|
|
423
|
+
* and linear in between — so two neighbours read `0.5` each at the midpoint of a
|
|
424
|
+
* drag. This is what the label's colour crossfades on, and because `position` is
|
|
425
|
+
* written by the pan *and* by the settle spring, one function covers a finger
|
|
426
|
+
* dragging, a flick, and a plain tap without knowing which is happening.
|
|
427
|
+
*
|
|
428
|
+
* Distinct from {@link resolveVisualIndex}, which is a discrete swap for the
|
|
429
|
+
* things that cannot fade — a composed `Icon` takes its colour as a resolved
|
|
430
|
+
* value, not a style, so it has no way to be half way between two.
|
|
431
|
+
*
|
|
432
|
+
* Self-contained, for the reason {@link resolvePanPosition} gives.
|
|
433
|
+
*/
|
|
434
|
+
export function resolveTabSelectedness(index: number, position: number): number {
|
|
435
|
+
"worklet";
|
|
436
|
+
if (index < 0) return 0;
|
|
437
|
+
const distance = Math.abs(position - index);
|
|
438
|
+
if (distance >= 1) return 0;
|
|
439
|
+
return 1 - distance;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Which tab is VISUALLY current — the one the indicator mostly covers.
|
|
444
|
+
*
|
|
445
|
+
* Distinct from the settled index, and both names are needed. The settled index is
|
|
446
|
+
* what a screen reader announces, what a panel hides on and what `onValueChange`
|
|
447
|
+
* reported; this one is only what things are PAINTED from, and it changes the
|
|
448
|
+
* moment a dragged capsule covers more of the next tab than the current one.
|
|
449
|
+
*
|
|
450
|
+
* Without it a capsule halfway across a drag carries the incoming label's
|
|
451
|
+
* unselected colour on top of the fill it has already covered, which is
|
|
452
|
+
* unreadable for the whole of the travel. `TABS_VISUAL_HYSTERESIS` is what keeps a finger held at the midpoint
|
|
453
|
+
* from flipping it every frame.
|
|
454
|
+
*/
|
|
455
|
+
export function resolveVisualIndex(position: number, current: number, count: number): number {
|
|
456
|
+
"worklet";
|
|
457
|
+
const max = count - 1;
|
|
458
|
+
if (max < 0) return 0;
|
|
459
|
+
if (Math.abs(position - current) <= 0.5 + TABS_VISUAL_HYSTERESIS) return current;
|
|
460
|
+
|
|
461
|
+
let next = Math.round(position);
|
|
462
|
+
if (next < 0) next = 0;
|
|
463
|
+
if (next > max) next = max;
|
|
464
|
+
return next;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* How visible a separator is, given where the pager sits.
|
|
469
|
+
*
|
|
470
|
+
* A rule fades out only while the pager is **crossing** it, and is fully present
|
|
471
|
+
* the rest of the time — including when the pager is parked on either of the two
|
|
472
|
+
* tabs it sits between. So a bar at rest always shows every one of its rules, and
|
|
473
|
+
* a drag dips the single rule it travels over and brings it back.
|
|
474
|
+
*
|
|
475
|
+
* The first shape of this hid every rule *flanking* the active tab, which is a
|
|
476
|
+
* different thing and reads badly: on a three-tab bar it leaves exactly one rule
|
|
477
|
+
* visible, over on the far side, looking arbitrary — and the set of visible rules
|
|
478
|
+
* changed every time the tab did, so the bar never looked still. Measuring from
|
|
479
|
+
* the gap's own midpoint instead makes the fade mean one thing: the capsule is
|
|
480
|
+
* on top of this rule right now.
|
|
481
|
+
*
|
|
482
|
+
* Driven by `position` rather than by the settled value, so the rule retreats
|
|
483
|
+
* under a finger and comes back if the drag is abandoned — which is the whole
|
|
484
|
+
* reason it is an animation and not a conditional render.
|
|
485
|
+
*
|
|
486
|
+
* A pair naming a tab nothing claims returns 1: a separator that vanished for a
|
|
487
|
+
* reason unexplainable from the call site would be worse than one that never
|
|
488
|
+
* fades.
|
|
489
|
+
*
|
|
490
|
+
* Self-contained, for the reason {@link resolvePanPosition} gives.
|
|
491
|
+
*/
|
|
492
|
+
export function resolveSeparatorOpacity(position: number, leftIndex: number, rightIndex: number): number {
|
|
493
|
+
"worklet";
|
|
494
|
+
if (leftIndex < 0 || rightIndex < 0) return 1;
|
|
495
|
+
|
|
496
|
+
const midpoint = (leftIndex + rightIndex) / 2;
|
|
497
|
+
const distance = Math.abs(position - midpoint);
|
|
498
|
+
|
|
499
|
+
const span = TABS_SEPARATOR_FADE.distance - TABS_SEPARATOR_FADE.hold;
|
|
500
|
+
if (span <= 0) return distance > TABS_SEPARATOR_FADE.hold ? 1 : 0;
|
|
501
|
+
|
|
502
|
+
const t = (distance - TABS_SEPARATOR_FADE.hold) / span;
|
|
503
|
+
if (t < 0) return 0;
|
|
504
|
+
if (t > 1) return 1;
|
|
505
|
+
return t;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Where the bar should sit so the active trigger lands as `align` says.
|
|
510
|
+
*
|
|
511
|
+
* `none` returns the offset unchanged rather than 0, so the prop means "leave the
|
|
512
|
+
* bar alone" instead of "send it home" — the difference matters for a bar the user
|
|
513
|
+
* has scrolled by hand.
|
|
514
|
+
*
|
|
515
|
+
* `start` and `end` leave `TABS_SCROLL_INSET` of the neighbouring tab visible. A
|
|
516
|
+
* trigger flush against the viewport edge reads as the last one in the list, and a
|
|
517
|
+
* sliver of the next is the only thing that says otherwise.
|
|
518
|
+
*
|
|
519
|
+
* Both clamps are written out rather than shared, for the reason
|
|
520
|
+
* `resolvePanPosition` gives.
|
|
521
|
+
*/
|
|
522
|
+
export function resolveScrollOffset(state: {
|
|
523
|
+
align: TabsScrollAlign;
|
|
524
|
+
contentWidth: number;
|
|
525
|
+
currentOffset: number;
|
|
526
|
+
viewportWidth: number;
|
|
527
|
+
width: number;
|
|
528
|
+
x: number;
|
|
529
|
+
}): number {
|
|
530
|
+
"worklet";
|
|
531
|
+
if (state.align === "none") return state.currentOffset;
|
|
532
|
+
if (state.viewportWidth <= 0) return state.currentOffset;
|
|
533
|
+
|
|
534
|
+
const max = state.contentWidth - state.viewportWidth;
|
|
535
|
+
if (max <= 0) return 0;
|
|
536
|
+
|
|
537
|
+
let target: number;
|
|
538
|
+
if (state.align === "center") {
|
|
539
|
+
target = state.x + state.width / 2 - state.viewportWidth / 2;
|
|
540
|
+
} else if (state.align === "end") {
|
|
541
|
+
target = state.x + state.width - state.viewportWidth + TABS_SCROLL_INSET;
|
|
542
|
+
} else {
|
|
543
|
+
target = state.x - TABS_SCROLL_INSET;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (target < 0) return 0;
|
|
547
|
+
if (target > max) return max;
|
|
548
|
+
return target;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Styling for every part of a tab bar, and for the panels below it.
|
|
553
|
+
*
|
|
554
|
+
* One slotted `tv()` rather than a call per part, because `tabs-indicator.tsx`,
|
|
555
|
+
* `tabs-trigger.tsx` and the rest cannot import the root without closing a cycle
|
|
556
|
+
* (AGENTS.md rule 3) yet all of them read the same `variant` and `size`.
|
|
557
|
+
*
|
|
558
|
+
* **The `indicator` slot names no width, offset or transform, at any size.** All
|
|
559
|
+
* three are an animated style, and a class fighting a `useAnimatedStyle` for the
|
|
560
|
+
* same property is an indicator that never appears, with no error anywhere.
|
|
561
|
+
*
|
|
562
|
+
* **The `trigger` slot carries no `opacity-*`, in any cell.** The disabled fade
|
|
563
|
+
* lands on the label instead. `Pressable` now multiplies a className's opacity
|
|
564
|
+
* into its press, so a fade on the trigger would draw — but the label already
|
|
565
|
+
* fades, and the two would compound to a quarter. This is `Radio`'s arrangement.
|
|
566
|
+
*
|
|
567
|
+
* **Selection changes the label's colour and nothing else.** A weight change would
|
|
568
|
+
* re-measure the label, which moves the frame the indicator is sitting on, on
|
|
569
|
+
* every single tap. A test asserts the two class sets differ by exactly one
|
|
570
|
+
* `text-*`.
|
|
571
|
+
*
|
|
572
|
+
* **The trigger's height is a floor, never fixed.** `Text` respects OS font
|
|
573
|
+
* scaling, so an `h-*` would clip a label at a large accessibility step; unlike a
|
|
574
|
+
* button or a field, a tab lines up against no chrome that would force the number.
|
|
575
|
+
* That is also why no `--spacing-tabs-*` is minted — it would be three numbers to
|
|
576
|
+
* retune in step with three others forever, `Checkbox`'s argument against
|
|
577
|
+
* `--spacing-checkbox-*`.
|
|
578
|
+
*
|
|
579
|
+
* **`page` is `w-full shrink-0`, and both halves are load-bearing.** `w-full`
|
|
580
|
+
* resolves against the row, which is the viewport's width, so the pages are laid
|
|
581
|
+
* out correctly on the FIRST paint with nothing measured; a row sized from a
|
|
582
|
+
* measurement would show every panel collapsed to zero width for a commit.
|
|
583
|
+
* `shrink-0` is what stops flex shrinking eight overflowing panels to an eighth
|
|
584
|
+
* each.
|
|
585
|
+
*
|
|
586
|
+
* Free of React Native imports so it stays unit-testable — `bun test` cannot parse
|
|
587
|
+
* React Native's Flow-typed source. See AGENTS.md.
|
|
588
|
+
*/
|
|
589
|
+
export const tabsVariants = tv({
|
|
590
|
+
slots: {
|
|
591
|
+
/** The component's column: the bar, then the panels. */
|
|
592
|
+
root: "",
|
|
593
|
+
/** The track. Paints a surface on `primary` alone. Clips, so the capsule cannot round its own corners past it. */
|
|
594
|
+
list: "overflow-hidden",
|
|
595
|
+
/** The horizontal scroller's own box. Never grows — the bar is as tall as its row. */
|
|
596
|
+
scroll: "grow-0",
|
|
597
|
+
/** The trigger row. Worn by `Tabs.List`, or by the scroller's content container. */
|
|
598
|
+
row: "flex-row items-stretch",
|
|
599
|
+
/** The one sliding layer. Its offset and width are an animated style, never a class. */
|
|
600
|
+
indicator: "absolute",
|
|
601
|
+
/** The pressable. Paints no surface — the indicator does. */
|
|
602
|
+
trigger: "flex-row items-center justify-center",
|
|
603
|
+
/**
|
|
604
|
+
* Handed to a `Text.Label`. Layout only — no scale, and no colour.
|
|
605
|
+
*
|
|
606
|
+
* The colour is an animated style rather than a class, because it *fades*:
|
|
607
|
+
* it interpolates between the two values {@link TABS_FOREGROUND_TOKEN}
|
|
608
|
+
* names, off the same `position` everything else in the component reads. A
|
|
609
|
+
* class here would be a second source for one colour, and a class and a
|
|
610
|
+
* style disagreeing for a frame is exactly what `Checkbox`'s animated border
|
|
611
|
+
* exists to avoid.
|
|
612
|
+
*/
|
|
613
|
+
label: "shrink",
|
|
614
|
+
/** Published to any glyph composed into a trigger, so a bare `Icon` needs nothing said. */
|
|
615
|
+
icon: "",
|
|
616
|
+
/**
|
|
617
|
+
* Positioning for a vertical rule. The line itself is a `Separator`.
|
|
618
|
+
*
|
|
619
|
+
* `flex-row` is load-bearing and its absence is silent. A vertical
|
|
620
|
+
* `Separator` is `self-stretch w-px`, so it takes its length from the cross
|
|
621
|
+
* axis of whatever holds it — in a column that axis is horizontal, and the
|
|
622
|
+
* rule comes out full width and zero height, which draws nothing at all.
|
|
623
|
+
*/
|
|
624
|
+
separator: "flex-row self-center",
|
|
625
|
+
/** The pager's viewport. */
|
|
626
|
+
pager: "overflow-hidden",
|
|
627
|
+
/** The row of panels the pager translates. */
|
|
628
|
+
pageRow: "flex-row items-stretch",
|
|
629
|
+
/** One panel, exactly one viewport wide. */
|
|
630
|
+
page: "w-full shrink-0",
|
|
631
|
+
},
|
|
632
|
+
variants: {
|
|
633
|
+
variant: {
|
|
634
|
+
primary: { list: "rounded-full bg-muted", indicator: "inset-y-0 rounded-full bg-elevated" },
|
|
635
|
+
secondary: { indicator: "bottom-0 h-0.5 rounded-full bg-primary" },
|
|
636
|
+
},
|
|
637
|
+
size: {
|
|
638
|
+
sm: { root: "gap-3", row: "gap-1.5", trigger: "min-h-9 gap-1 px-3", separator: "h-4", icon: "size-icon-sm" },
|
|
639
|
+
md: { root: "gap-4", row: "gap-2", trigger: "min-h-11 gap-1.5 px-4", separator: "h-5", icon: "size-icon-md" },
|
|
640
|
+
lg: { root: "gap-5", row: "gap-2.5", trigger: "min-h-12 gap-2 px-5", separator: "h-6", icon: "size-icon-lg" },
|
|
641
|
+
},
|
|
642
|
+
// The empty branches are load-bearing typing, not placeholders. `tv` derives
|
|
643
|
+
// the prop type from the declared keys, so a map with only `true` types the
|
|
644
|
+
// prop as `true` rather than `boolean`. See button.variants.ts.
|
|
645
|
+
//
|
|
646
|
+
// A trigger fills the bar when the row does not scroll and sizes to its own
|
|
647
|
+
// content when it does. `flex-1` inside a scroller would set a basis of 0%,
|
|
648
|
+
// which Yoga resolves to zero in a content-sized row — the same collapse
|
|
649
|
+
// `Radio`'s label avoids by taking `shrink` rather than `flex-1`.
|
|
650
|
+
isScrollable: { true: {}, false: { trigger: "flex-1" } },
|
|
651
|
+
isDisabled: { true: { label: "opacity-50" }, false: {} },
|
|
652
|
+
},
|
|
653
|
+
compoundVariants: [
|
|
654
|
+
// The track's padding belongs to the variant that draws a track: carried on
|
|
655
|
+
// `size` alone it would inset an underline that has no track to sit in. The
|
|
656
|
+
// corner is not here — both the track and the capsule are `rounded-full` on
|
|
657
|
+
// the variant itself, at every size.
|
|
658
|
+
{ variant: "primary", size: "sm", class: { list: "p-0.5" } },
|
|
659
|
+
{ variant: "primary", size: "md", class: { list: "p-1" } },
|
|
660
|
+
{ variant: "primary", size: "lg", class: { list: "p-1" } },
|
|
661
|
+
],
|
|
662
|
+
defaultVariants: {
|
|
663
|
+
variant: TABS_DEFAULT_VARIANT,
|
|
664
|
+
size: TABS_DEFAULT_SIZE,
|
|
665
|
+
isScrollable: false,
|
|
666
|
+
isDisabled: false,
|
|
667
|
+
},
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
export type TabsVariantProps = VariantProps<typeof tabsVariants>;
|