@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,435 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
isValidElement,
|
|
4
|
+
type ReactElement,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
useCallback,
|
|
7
|
+
useEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useReducer,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
12
|
+
} from "react";
|
|
13
|
+
import { View, type ViewProps } from "react-native";
|
|
14
|
+
import { Gesture } from "react-native-gesture-handler";
|
|
15
|
+
import { cancelAnimation, useAnimatedReaction, useSharedValue, withSpring } from "react-native-reanimated";
|
|
16
|
+
import { scheduleOnRN } from "react-native-worklets";
|
|
17
|
+
import { useControllableState } from "../../hooks/use-controllable-state";
|
|
18
|
+
import { type TabsContextValue, TabsMotionProvider, type TabsMotionValue, TabsProvider } from "./tabs.context";
|
|
19
|
+
import {
|
|
20
|
+
resolveInitialValue,
|
|
21
|
+
resolvePanOrigin,
|
|
22
|
+
resolvePanPosition,
|
|
23
|
+
resolveReconcileMode,
|
|
24
|
+
resolveSettleIndex,
|
|
25
|
+
resolveTabIndex,
|
|
26
|
+
resolveTabOrder,
|
|
27
|
+
resolveVisualIndex,
|
|
28
|
+
shouldEmitTabChange,
|
|
29
|
+
TABS_DEFAULT_SIZE,
|
|
30
|
+
TABS_DEFAULT_VARIANT,
|
|
31
|
+
TABS_PAN,
|
|
32
|
+
TABS_SETTLE_SPRING,
|
|
33
|
+
type TabsSize,
|
|
34
|
+
type TabsVariant,
|
|
35
|
+
tabsVariants,
|
|
36
|
+
} from "./tabs.variants";
|
|
37
|
+
import { TabsContent } from "./tabs-content";
|
|
38
|
+
import { TabsIndicator } from "./tabs-indicator";
|
|
39
|
+
import { TabsLabel } from "./tabs-label";
|
|
40
|
+
import { TabsList } from "./tabs-list";
|
|
41
|
+
import { TabsPager } from "./tabs-pager";
|
|
42
|
+
import { TabsScrollView } from "./tabs-scroll-view";
|
|
43
|
+
import { TabsSeparator } from "./tabs-separator";
|
|
44
|
+
import { TabsTrigger } from "./tabs-trigger";
|
|
45
|
+
|
|
46
|
+
export type TabsProps = Omit<ViewProps, "children"> & {
|
|
47
|
+
/**
|
|
48
|
+
* The selected tab's value.
|
|
49
|
+
*
|
|
50
|
+
* `null` means "controlled, nothing selected". Omitting the prop entirely is
|
|
51
|
+
* what makes the bar uncontrolled, so a `useState<string>()` seeded with
|
|
52
|
+
* `undefined` would silently hand the component its own state and then switch
|
|
53
|
+
* it to controlled on the first press. Pass `tab ?? null`.
|
|
54
|
+
*/
|
|
55
|
+
value?: string | null;
|
|
56
|
+
/** The tab selected to begin with, while uncontrolled. Defaults to the first. */
|
|
57
|
+
defaultValue?: string | null;
|
|
58
|
+
/** Called with the newly selected value. Never called for a re-press of the current one. */
|
|
59
|
+
onValueChange?: (value: string) => void;
|
|
60
|
+
/** How the bar is painted: a segmented track, an underline, or a floating capsule. */
|
|
61
|
+
variant?: TabsVariant;
|
|
62
|
+
/** Drives the trigger's floor and padding, the gap ladder and the label's step, on one axis. */
|
|
63
|
+
size?: TabsSize;
|
|
64
|
+
/**
|
|
65
|
+
* A horizontal pan on the panels drags them and drives the indicator with it.
|
|
66
|
+
*
|
|
67
|
+
* On by default, and it is exactly one thing: whether the gesture is enabled.
|
|
68
|
+
* It does not change what mounts — every panel is mounted either way, because
|
|
69
|
+
* you cannot drag to one that is not there and because a panel that unmounted
|
|
70
|
+
* would lose what was typed into it. See `Tabs.Content` for the per-panel
|
|
71
|
+
* escape hatch.
|
|
72
|
+
*/
|
|
73
|
+
isSwipeable?: boolean;
|
|
74
|
+
/** Disables every trigger. A trigger that names the axis still wins, either way. */
|
|
75
|
+
isDisabled?: boolean;
|
|
76
|
+
className?: string;
|
|
77
|
+
children?: ReactNode;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function TabsRoot({
|
|
81
|
+
value: valueProp,
|
|
82
|
+
defaultValue,
|
|
83
|
+
onValueChange,
|
|
84
|
+
variant = TABS_DEFAULT_VARIANT,
|
|
85
|
+
size = TABS_DEFAULT_SIZE,
|
|
86
|
+
isSwipeable = true,
|
|
87
|
+
isDisabled,
|
|
88
|
+
className,
|
|
89
|
+
children,
|
|
90
|
+
...props
|
|
91
|
+
}: TabsProps): ReactElement {
|
|
92
|
+
const { content, values } = useMemo(() => withPager(children), [children]);
|
|
93
|
+
|
|
94
|
+
// Only consulted for a bar with no panels at all — a filter row driving a list
|
|
95
|
+
// somewhere else. With panels present the registration is a no-op, so a bar
|
|
96
|
+
// that has them does not re-render once per trigger on mount.
|
|
97
|
+
const hasPanels = values.length > 0;
|
|
98
|
+
const [registered, setRegistered] = useState<readonly string[]>([]);
|
|
99
|
+
const registerValue = useCallback(
|
|
100
|
+
(value: string) => {
|
|
101
|
+
if (hasPanels) return noop;
|
|
102
|
+
setRegistered((current) => (current.includes(value) ? current : [...current, value]));
|
|
103
|
+
return () => setRegistered((current) => current.filter((name) => name !== value));
|
|
104
|
+
},
|
|
105
|
+
[hasPanels]
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const order = useMemo(() => resolveTabOrder(values, registered), [values, registered]);
|
|
109
|
+
const count = order.length;
|
|
110
|
+
|
|
111
|
+
const [selected, setSelected] = useControllableState<string | null>({
|
|
112
|
+
value: valueProp,
|
|
113
|
+
defaultValue: resolveInitialValue(order, defaultValue),
|
|
114
|
+
onChange: handleValueChange(onValueChange),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const selectedIndex = resolveTabIndex(order, selected);
|
|
118
|
+
|
|
119
|
+
const position = useSharedValue(selectedIndex < 0 ? 0 : selectedIndex);
|
|
120
|
+
const pageWidth = useSharedValue(0);
|
|
121
|
+
const panStart = useSharedValue(0);
|
|
122
|
+
// Whether the pan actually became active. `onFinalize` fires for every touch
|
|
123
|
+
// the pager sees, including ones that never activated, and it must not settle
|
|
124
|
+
// those — see the gesture below.
|
|
125
|
+
const isPanning = useSharedValue(false);
|
|
126
|
+
|
|
127
|
+
// Where `position` is settled or heading. A plain ref rather than a shared
|
|
128
|
+
// value: only the JS thread ever asks, and a `.value` read from an effect
|
|
129
|
+
// closure is the strict-mode hazard `use-screen-scroll-insets` documents.
|
|
130
|
+
const target = useRef({ index: selectedIndex < 0 ? 0 : selectedIndex, value: selected });
|
|
131
|
+
|
|
132
|
+
// Bumped alongside every commit, and being unread in the body is the point. A
|
|
133
|
+
// CONTROLLED parent that rejects a change re-renders nothing, so a reconcile
|
|
134
|
+
// waiting on the parent's commit would never run and the pager would sit on a
|
|
135
|
+
// panel the caller's state says is not selected. `Switch` carries the same
|
|
136
|
+
// token for the same reason.
|
|
137
|
+
const [commits, requestReconcile] = useReducer((n: number) => n + 1, 0);
|
|
138
|
+
|
|
139
|
+
const selectedRef = useRef(selected);
|
|
140
|
+
selectedRef.current = selected;
|
|
141
|
+
|
|
142
|
+
const select = useCallback(
|
|
143
|
+
(next: string) => {
|
|
144
|
+
if (!shouldEmitTabChange(selectedRef.current, next)) return;
|
|
145
|
+
setSelected(next);
|
|
146
|
+
requestReconcile();
|
|
147
|
+
},
|
|
148
|
+
[setSelected]
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// Called from the pan alone. The gesture has ALREADY started the settle spring,
|
|
152
|
+
// so this records where it aimed — which is what makes the reconcile below a
|
|
153
|
+
// no-op when the change is accepted, leaving the fling its momentum, and a
|
|
154
|
+
// spring back when it is not.
|
|
155
|
+
const orderRef = useRef(order);
|
|
156
|
+
orderRef.current = order;
|
|
157
|
+
const commitFromPan = useCallback(
|
|
158
|
+
(nextIndex: number) => {
|
|
159
|
+
const nextValue = orderRef.current[nextIndex];
|
|
160
|
+
if (nextValue === undefined) return;
|
|
161
|
+
target.current = { index: nextIndex, value: nextValue };
|
|
162
|
+
setSelected(nextValue);
|
|
163
|
+
requestReconcile();
|
|
164
|
+
},
|
|
165
|
+
[setSelected]
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
// Deliberately without a dependency array: it runs after every commit and
|
|
169
|
+
// repairs any divergence between where `position` is heading and what React
|
|
170
|
+
// says is selected. Two ref reads and a comparison — the guard is what keeps it
|
|
171
|
+
// from being three springs a second.
|
|
172
|
+
//
|
|
173
|
+
// **It registers no cleanup, and that is the whole of a bug this component
|
|
174
|
+
// shipped once.** `Checkbox` and `Switch` end their effects with
|
|
175
|
+
// `cancelAnimation`, and both can: their effect starts a fresh animation on
|
|
176
|
+
// every run, so a cleanup that cancels the previous one always has a
|
|
177
|
+
// replacement behind it. This effect does not — its `none` branch returns
|
|
178
|
+
// early — and the commit that runs the cleanup is the one the *gesture* just
|
|
179
|
+
// caused. So a swipe would start its settle spring, `commitFromPan` would
|
|
180
|
+
// re-render, the previous run's cleanup would cancel that spring, and the new
|
|
181
|
+
// run would take the `none` branch and restart nothing. The pager froze part
|
|
182
|
+
// way between two panels, intermittently, depending on whether the commit beat
|
|
183
|
+
// the spring. An in-flight spring on an unmounted bar is harmless — the shared
|
|
184
|
+
// value goes with the component — so there is nothing here to clean up.
|
|
185
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `commits` is the re-run token, see above
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
const mode = resolveReconcileMode({
|
|
188
|
+
selectedIndex,
|
|
189
|
+
selectedValue: selected,
|
|
190
|
+
targetIndex: target.current.index,
|
|
191
|
+
targetValue: target.current.value,
|
|
192
|
+
});
|
|
193
|
+
if (mode === "none") return;
|
|
194
|
+
|
|
195
|
+
target.current = { index: selectedIndex, value: selected };
|
|
196
|
+
position.value = mode === "jump" ? selectedIndex : withSpring(selectedIndex, TABS_SETTLE_SPRING);
|
|
197
|
+
}, [commits, position, selected, selectedIndex]);
|
|
198
|
+
|
|
199
|
+
const panGesture = useMemo(
|
|
200
|
+
() =>
|
|
201
|
+
Gesture.Pan()
|
|
202
|
+
// One builder call is the whole of `isSwipeable`. Nothing else in the
|
|
203
|
+
// component branches on it.
|
|
204
|
+
.enabled(isSwipeable && count > 1)
|
|
205
|
+
// The pager claims a sideways drag and gives up a vertical one, which is
|
|
206
|
+
// what lets it live inside a scrolling screen at all. `blocksExternalGesture`
|
|
207
|
+
// is not the alternative: `Screen.ScrollArea` renders React Native's own
|
|
208
|
+
// `ScrollView`, which has no handler tag to resolve, so the call is
|
|
209
|
+
// dropped without an error — see `Slider.Track`.
|
|
210
|
+
.activeOffsetX([-TABS_PAN.activateX, TABS_PAN.activateX])
|
|
211
|
+
.failOffsetY([-TABS_PAN.failY, TABS_PAN.failY])
|
|
212
|
+
// The spring is cancelled on ACTIVATION, never on touch-down. `onBegin`
|
|
213
|
+
// fires for every touch the pager sees — a tap on a panel, the start of
|
|
214
|
+
// a vertical scroll — and most of those go on to FAIL against
|
|
215
|
+
// `failOffsetY`. Cancelling there would kill an in-flight settle for a
|
|
216
|
+
// gesture that never became a drag, and since `onEnd` only runs for a
|
|
217
|
+
// pan that actually activated, nothing would ever restart it: the pager
|
|
218
|
+
// freezes half way between two panels. Found by dragging, then scrolling
|
|
219
|
+
// the page before the spring had finished.
|
|
220
|
+
.onStart((event) => {
|
|
221
|
+
"worklet";
|
|
222
|
+
cancelAnimation(position);
|
|
223
|
+
isPanning.value = true;
|
|
224
|
+
// Interruptible: grabbing a pager mid-settle picks it up where it is
|
|
225
|
+
// rather than snapping to the target first.
|
|
226
|
+
panStart.value = resolvePanOrigin(position.value, event.translationX, pageWidth.value);
|
|
227
|
+
})
|
|
228
|
+
.onUpdate((event) => {
|
|
229
|
+
"worklet";
|
|
230
|
+
position.value = resolvePanPosition({
|
|
231
|
+
count,
|
|
232
|
+
pageWidth: pageWidth.value,
|
|
233
|
+
startPosition: panStart.value,
|
|
234
|
+
translationX: event.translationX,
|
|
235
|
+
});
|
|
236
|
+
})
|
|
237
|
+
// `onFinalize`, not `onEnd`, because it is the one callback that runs on
|
|
238
|
+
// every path out of the gesture — END, FAILED and CANCELLED alike, from
|
|
239
|
+
// any state. `Slider.Track` states the same rule for the same reason. A
|
|
240
|
+
// drag cancelled mid-flight by the OS or by another handler reaches only
|
|
241
|
+
// this one, and without it the pager would be left wherever the finger
|
|
242
|
+
// happened to be. The flag is what keeps a touch that never activated
|
|
243
|
+
// from retargeting a spring it never disturbed.
|
|
244
|
+
.onFinalize((event, success) => {
|
|
245
|
+
"worklet";
|
|
246
|
+
if (!isPanning.value) return;
|
|
247
|
+
isPanning.value = false;
|
|
248
|
+
|
|
249
|
+
const width = pageWidth.value;
|
|
250
|
+
// Index units per second, so the threshold means the same flick on a
|
|
251
|
+
// phone as on a tablet.
|
|
252
|
+
const velocity = success && width > 0 ? -event.velocityX / width : 0;
|
|
253
|
+
const startIndex = Math.round(panStart.value);
|
|
254
|
+
const next = resolveSettleIndex({ count, position: position.value, startIndex, velocity });
|
|
255
|
+
|
|
256
|
+
position.value = withSpring(next, { ...TABS_SETTLE_SPRING, velocity });
|
|
257
|
+
|
|
258
|
+
if (next !== startIndex) scheduleOnRN(commitFromPan, next);
|
|
259
|
+
}),
|
|
260
|
+
[commitFromPan, count, isPanning, isSwipeable, pageWidth, panStart, position]
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
// The visual selection, and the one hop from the UI thread back to React that
|
|
264
|
+
// is not the value itself. Without it a filled capsule sitting halfway over the
|
|
265
|
+
// next tab carries that tab's unselected label colour, which is unreadable for
|
|
266
|
+
// the whole of a drag. The hysteresis band is what stops a finger held at the
|
|
267
|
+
// midpoint from flipping it every frame.
|
|
268
|
+
const [visualIndex, setVisualIndex] = useState(selectedIndex < 0 ? 0 : selectedIndex);
|
|
269
|
+
const visual = useSharedValue(selectedIndex < 0 ? 0 : selectedIndex);
|
|
270
|
+
|
|
271
|
+
useAnimatedReaction(
|
|
272
|
+
() => resolveVisualIndex(position.value, visual.value, count),
|
|
273
|
+
(current, previous) => {
|
|
274
|
+
if (current === previous) return;
|
|
275
|
+
visual.value = current;
|
|
276
|
+
scheduleOnRN(setVisualIndex, current);
|
|
277
|
+
},
|
|
278
|
+
[count]
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const context = useMemo<TabsContextValue>(
|
|
282
|
+
() => ({ isDisabled, isSwipeable, order, registerValue, select, size, value: selected, variant, visualIndex }),
|
|
283
|
+
[isDisabled, isSwipeable, order, registerValue, select, size, selected, variant, visualIndex]
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
const motion = useMemo<TabsMotionValue>(
|
|
287
|
+
() => ({ pageWidth, panGesture, position }),
|
|
288
|
+
[pageWidth, panGesture, position]
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
return (
|
|
292
|
+
<TabsProvider value={context}>
|
|
293
|
+
<TabsMotionProvider value={motion}>
|
|
294
|
+
<View className={tabsVariants({ size, variant }).root({ className })} {...props}>
|
|
295
|
+
{content}
|
|
296
|
+
</View>
|
|
297
|
+
</TabsMotionProvider>
|
|
298
|
+
</TabsProvider>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** A stable no-op, so a registration that does nothing does not mint a function per render. */
|
|
303
|
+
function noop(): void {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Adapts `useControllableState`'s nullable setter to `onValueChange`.
|
|
309
|
+
*
|
|
310
|
+
* Declared outside the render so the identity is stable per `onValueChange`, or
|
|
311
|
+
* the setter rebuilds every render, which rebuilds the context, which re-renders
|
|
312
|
+
* every trigger in the bar — `Radio.Group`'s note.
|
|
313
|
+
*/
|
|
314
|
+
function handleValueChange(onValueChange?: (value: string) => void): (next: string | null) => void {
|
|
315
|
+
return (next) => {
|
|
316
|
+
if (next !== null) onValueChange?.(next);
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Collects every `Tabs.Content` child into one pager, in place of the first.
|
|
322
|
+
*
|
|
323
|
+
* The panels are the ORDER OF RECORD: their source order is what the pager
|
|
324
|
+
* translates through, what the indicator interpolates over and what the pan snaps
|
|
325
|
+
* to. Triggers are not walked at all — a trigger finds its own index by looking
|
|
326
|
+
* its `value` up in this list, so it may sit behind any wrapper, come out of a
|
|
327
|
+
* `.map()`, or be written in a different order without anything moving.
|
|
328
|
+
*
|
|
329
|
+
* That is why the panels are the ones required to be direct children rather than
|
|
330
|
+
* the triggers. Walking triggers would break the moment anyone wrote
|
|
331
|
+
* `<FilterTabs />` or wrapped a pair in a `View`: the walk would see a component
|
|
332
|
+
* it does not recognise and silently drop tabs from the list. A panel is the one
|
|
333
|
+
* part nobody has a reason to wrap.
|
|
334
|
+
*
|
|
335
|
+
* The pager takes the place of the FIRST panel rather than being appended, so a
|
|
336
|
+
* `Tabs.List` above and anything below keep the positions the caller wrote them
|
|
337
|
+
* in. `Children.toArray` drops the nulls a conditional panel leaves behind, the
|
|
338
|
+
* same reason `withDividers` calls it.
|
|
339
|
+
*
|
|
340
|
+
* Lives here rather than with the parts: it is the root that rearranges its own
|
|
341
|
+
* children, and importing it from a part would close a cycle. See AGENTS.md.
|
|
342
|
+
*/
|
|
343
|
+
function withPager(children: ReactNode): { content: ReactNode; values: string[] } {
|
|
344
|
+
const items = Children.toArray(children);
|
|
345
|
+
const panels: ReactNode[] = [];
|
|
346
|
+
const values: string[] = [];
|
|
347
|
+
|
|
348
|
+
for (const child of items) {
|
|
349
|
+
if (!isValidElement(child) || child.type !== TabsContent) continue;
|
|
350
|
+
panels.push(child);
|
|
351
|
+
values.push((child.props as { value: string }).value);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (panels.length === 0) return { content: items, values };
|
|
355
|
+
|
|
356
|
+
const output: ReactNode[] = [];
|
|
357
|
+
let placed = false;
|
|
358
|
+
|
|
359
|
+
for (const child of items) {
|
|
360
|
+
if (isValidElement(child) && child.type === TabsContent) {
|
|
361
|
+
if (placed) continue;
|
|
362
|
+
output.push(<TabsPager key="pager">{panels}</TabsPager>);
|
|
363
|
+
placed = true;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
output.push(child);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return { content: output, values };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* A row of tabs, and the panels they switch between.
|
|
374
|
+
*
|
|
375
|
+
* The whole component moves off one shared value: a float index into the panels'
|
|
376
|
+
* own order. A press springs it, a drag writes it directly, and the indicator, the
|
|
377
|
+
* panels and every separator read it — so the capsule follows a finger through a
|
|
378
|
+
* swipe rather than snapping when it is let go, and nothing can drift a frame out
|
|
379
|
+
* of step with anything else.
|
|
380
|
+
*
|
|
381
|
+
* **The panels are the order of record and must be direct children**, the same ask
|
|
382
|
+
* `ListGroup` makes of its rows. Triggers may be written any way at all; each one
|
|
383
|
+
* finds its place by its `value`.
|
|
384
|
+
*
|
|
385
|
+
* **State works either way from one hook**: pass `value` to control it, or nothing
|
|
386
|
+
* and let the bar hold its own, starting on the first tab.
|
|
387
|
+
*
|
|
388
|
+
* A trigger with no text needs an `accessibilityLabel`, the same rule an icon-sized
|
|
389
|
+
* `Button` follows.
|
|
390
|
+
*
|
|
391
|
+
* @example
|
|
392
|
+
* <Tabs onValueChange={setTab} value={tab}>
|
|
393
|
+
* <Tabs.List>
|
|
394
|
+
* <Tabs.Indicator />
|
|
395
|
+
* <Tabs.Trigger value="overview">Overview</Tabs.Trigger>
|
|
396
|
+
* <Tabs.Trigger value="activity">Activity</Tabs.Trigger>
|
|
397
|
+
* </Tabs.List>
|
|
398
|
+
* <Tabs.Content value="overview"><Overview /></Tabs.Content>
|
|
399
|
+
* <Tabs.Content value="activity"><Activity /></Tabs.Content>
|
|
400
|
+
* </Tabs>
|
|
401
|
+
*
|
|
402
|
+
* @example
|
|
403
|
+
* <Tabs variant="secondary">
|
|
404
|
+
* <Tabs.List>
|
|
405
|
+
* <Tabs.ScrollView scrollAlign="center">
|
|
406
|
+
* <Tabs.Indicator />
|
|
407
|
+
* {sections.map((section) => (
|
|
408
|
+
* <Tabs.Trigger key={section.id} value={section.id}>
|
|
409
|
+
* <Tabs.Label>{section.title}</Tabs.Label>
|
|
410
|
+
* </Tabs.Trigger>
|
|
411
|
+
* ))}
|
|
412
|
+
* </Tabs.ScrollView>
|
|
413
|
+
* </Tabs.List>
|
|
414
|
+
* {sections.map((section) => (
|
|
415
|
+
* <Tabs.Content key={section.id} value={section.id}>{section.body}</Tabs.Content>
|
|
416
|
+
* ))}
|
|
417
|
+
* </Tabs>
|
|
418
|
+
*/
|
|
419
|
+
export const Tabs = Object.assign(TabsRoot, {
|
|
420
|
+
/** The bar: the track, and the frame every trigger measures itself into. */
|
|
421
|
+
List: TabsList,
|
|
422
|
+
/** A horizontal scroller for a bar with more tabs than room. */
|
|
423
|
+
ScrollView: TabsScrollView,
|
|
424
|
+
/** The one layer that slides. Write it out as the row's first child. */
|
|
425
|
+
Indicator: TabsIndicator,
|
|
426
|
+
/** One tab. A `Pressable`, so it inherits the whole vocabulary. */
|
|
427
|
+
Trigger: TabsTrigger,
|
|
428
|
+
/** A trigger's text. Bare string children become one automatically. */
|
|
429
|
+
Label: TabsLabel,
|
|
430
|
+
/** A hairline between two tabs, which retreats as either is approached. */
|
|
431
|
+
Separator: TabsSeparator,
|
|
432
|
+
/** One panel. Must be a direct child of `Tabs` — its place in the source is its place in the order. */
|
|
433
|
+
Content: TabsContent,
|
|
434
|
+
displayName: "DelacourUI.Tabs",
|
|
435
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { TextPresetProps } from "../text";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The shape of a trigger's text part.
|
|
5
|
+
*
|
|
6
|
+
* Lives in a leaf rather than in `tabs-label.tsx` because `tabs-trigger.tsx`
|
|
7
|
+
* imports it too — the trigger wraps bare string children in a `Tabs.Label`, and
|
|
8
|
+
* reaching for the type through the part file would be reaching past what that
|
|
9
|
+
* file is for. The same two-consumer shape that put `CheckboxLabelProps` here.
|
|
10
|
+
*
|
|
11
|
+
* `variant` is already omitted by `TextPresetProps`: the part *is* one.
|
|
12
|
+
*/
|
|
13
|
+
export type TabsLabelProps = TextPresetProps;
|