@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,139 @@
|
|
|
1
|
+
import { Children, isValidElement, type ReactElement, type ReactNode, useCallback, useMemo, useRef } from "react";
|
|
2
|
+
import { View, type ViewProps } from "react-native";
|
|
3
|
+
import { useSharedValue } from "react-native-reanimated";
|
|
4
|
+
import { cn } from "../../lib/cn";
|
|
5
|
+
import { TabsListProvider, type TabsListValue, useTabsPart } from "./tabs.context";
|
|
6
|
+
import {
|
|
7
|
+
isTriggerOrderConsistent,
|
|
8
|
+
resolveMeasurementTracks,
|
|
9
|
+
type TabMeasurement,
|
|
10
|
+
type TabTracks,
|
|
11
|
+
tabsVariants,
|
|
12
|
+
} from "./tabs.variants";
|
|
13
|
+
import { TabsScrollView } from "./tabs-scroll-view";
|
|
14
|
+
|
|
15
|
+
export type TabsListProps = ViewProps & {
|
|
16
|
+
className?: string;
|
|
17
|
+
children?: ReactNode;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The bar itself: the track, and the frame every trigger measures itself into.
|
|
22
|
+
*
|
|
23
|
+
* It owns the measurement registry, which is why `Tabs.Indicator` and
|
|
24
|
+
* `Tabs.Trigger` throw by name outside one — an indicator with nothing to measure
|
|
25
|
+
* has no other way of saying so.
|
|
26
|
+
*
|
|
27
|
+
* **The registry is a JS-side map published as one shared value, coalesced to one
|
|
28
|
+
* write per tick.** Every `onLayout` in a layout pass lands in the same tick, so
|
|
29
|
+
* without the coalescing an eight-tab bar writes the shared value eight times and
|
|
30
|
+
* the UI thread can render between two of them — showing an indicator built from
|
|
31
|
+
* four new positions and four old ones. A microtask still runs before paint, so
|
|
32
|
+
* the coalescing costs nothing.
|
|
33
|
+
*
|
|
34
|
+
* **The track and the row are always two elements, never one.** It is tempting to
|
|
35
|
+
* put the track's padding and the row's layout on a single View when nothing
|
|
36
|
+
* scrolls — and it is wrong, silently. `Tabs.Indicator` is absolutely positioned,
|
|
37
|
+
* and an absolute child resolves its insets against its parent's *padding box*:
|
|
38
|
+
* `inset-y-0` would span the track's full height while the triggers sat inside
|
|
39
|
+
* the padding, so the capsule and the track would come out exactly the same
|
|
40
|
+
* height. Two pills of equal radius offset horizontally do not nest — their
|
|
41
|
+
* curves cross, and the ends show a sliver of track between them. Measured on a
|
|
42
|
+
* simulator at 3×: 12px of gap at the sides and 0px top and bottom.
|
|
43
|
+
*
|
|
44
|
+
* With the row as its own element the indicator's containing block is the row, so
|
|
45
|
+
* `inset-y-0` is exactly the trigger's height and the capsule is concentric on
|
|
46
|
+
* every axis. A `Tabs.ScrollView` already had this shape — its content container
|
|
47
|
+
* is the row — which is why the bug only ever showed on a bar that did not
|
|
48
|
+
* scroll. The two paths are now identical.
|
|
49
|
+
*/
|
|
50
|
+
export function TabsList({ className, children, ...props }: TabsListProps): ReactElement {
|
|
51
|
+
const { order, size, variant } = useTabsPart("Tabs.List");
|
|
52
|
+
|
|
53
|
+
// The JS-side source of truth. A ref rather than state: a layout re-renders
|
|
54
|
+
// nothing here, and the same map is what the indicator and the bar's own
|
|
55
|
+
// auto-scroll both read — one measurement, two consumers, so they can never
|
|
56
|
+
// disagree about where a tab is.
|
|
57
|
+
const measured = useRef<Record<string, TabMeasurement>>({});
|
|
58
|
+
const tracks = useSharedValue<TabTracks | null>(null);
|
|
59
|
+
const isPublishQueued = useRef(false);
|
|
60
|
+
|
|
61
|
+
// Read inside the microtask rather than captured when it was queued: a tab
|
|
62
|
+
// removed in between would otherwise publish a track for a value that no
|
|
63
|
+
// longer exists.
|
|
64
|
+
const orderRef = useRef(order);
|
|
65
|
+
orderRef.current = order;
|
|
66
|
+
|
|
67
|
+
const scrollX = useSharedValue(0);
|
|
68
|
+
const viewportWidth = useSharedValue(0);
|
|
69
|
+
const contentWidth = useSharedValue(0);
|
|
70
|
+
const isBarDragging = useSharedValue(false);
|
|
71
|
+
|
|
72
|
+
const publish = useCallback(() => {
|
|
73
|
+
if (isPublishQueued.current) return;
|
|
74
|
+
isPublishQueued.current = true;
|
|
75
|
+
queueMicrotask(() => {
|
|
76
|
+
isPublishQueued.current = false;
|
|
77
|
+
const next = resolveMeasurementTracks(orderRef.current, measured.current);
|
|
78
|
+
tracks.value = next;
|
|
79
|
+
|
|
80
|
+
if (process.env.NODE_ENV !== "production" && next && !isTriggerOrderConsistent(next.x)) {
|
|
81
|
+
console.warn(
|
|
82
|
+
"Tabs: the triggers do not run left to right in the order their panels are written, so the indicator will land on the wrong tab. Write <Tabs.Trigger> in the same order as <Tabs.Content>, and do not wrap a trigger in a view of its own — a wrapped trigger measures itself against the wrapper rather than the row."
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}, [tracks]);
|
|
87
|
+
|
|
88
|
+
const measure = useCallback(
|
|
89
|
+
(value: string, measurement: TabMeasurement) => {
|
|
90
|
+
const previous = measured.current[value];
|
|
91
|
+
// A re-layout reporting identical numbers is not a change, and writing the
|
|
92
|
+
// shared value anyway would invalidate every worklet reading it.
|
|
93
|
+
if (previous && previous.x === measurement.x && previous.width === measurement.width) return;
|
|
94
|
+
measured.current[value] = measurement;
|
|
95
|
+
publish();
|
|
96
|
+
},
|
|
97
|
+
[publish]
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const unmeasure = useCallback(
|
|
101
|
+
(value: string) => {
|
|
102
|
+
if (!(value in measured.current)) return;
|
|
103
|
+
delete measured.current[value];
|
|
104
|
+
publish();
|
|
105
|
+
},
|
|
106
|
+
[publish]
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const context = useMemo<TabsListValue>(
|
|
110
|
+
() => ({ contentWidth, isBarDragging, measure, scrollX, tracks, unmeasure, viewportWidth }),
|
|
111
|
+
[contentWidth, isBarDragging, measure, scrollX, tracks, unmeasure, viewportWidth]
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const isScrollable = useMemo(() => holdsScrollView(children), [children]);
|
|
115
|
+
const slots = tabsVariants({ isScrollable, size, variant });
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<TabsListProvider value={context}>
|
|
119
|
+
<View accessibilityRole="tablist" className={cn(slots.list(), className)} {...props}>
|
|
120
|
+
{isScrollable ? children : <View className={slots.row()}>{children}</View>}
|
|
121
|
+
</View>
|
|
122
|
+
</TabsListProvider>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
TabsList.displayName = "DelacourUI.Tabs.List";
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Whether the row is rendered by a `Tabs.ScrollView` inside this list.
|
|
129
|
+
*
|
|
130
|
+
* The scroller lays the triggers out in its own content container, so a list that
|
|
131
|
+
* also wore the row's layout would put a second flex row around it — a gap and a
|
|
132
|
+
* stretch that belong to a row of triggers, applied to a single scroll view.
|
|
133
|
+
*/
|
|
134
|
+
function holdsScrollView(children: ReactNode): boolean {
|
|
135
|
+
for (const child of Children.toArray(children)) {
|
|
136
|
+
if (isValidElement(child) && child.type === TabsScrollView) return true;
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type ReactElement, type ReactNode, useCallback } from "react";
|
|
2
|
+
import type { LayoutChangeEvent } from "react-native";
|
|
3
|
+
import { GestureDetector } from "react-native-gesture-handler";
|
|
4
|
+
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
|
5
|
+
import { useTabsMotionPart, useTabsPart } from "./tabs.context";
|
|
6
|
+
import { resolvePagerTranslate, tabsVariants } from "./tabs.variants";
|
|
7
|
+
|
|
8
|
+
export type TabsPagerProps = {
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The clipped viewport the panels travel through, and the surface the pan is
|
|
14
|
+
* claimed on.
|
|
15
|
+
*
|
|
16
|
+
* Internal: the root wraps its own run of `Tabs.Content` children in one of these,
|
|
17
|
+
* so there is no key for it on the compound surface and nothing for a caller to
|
|
18
|
+
* configure that `isSwipeable` does not already decide — `Checkbox.Indicator`'s
|
|
19
|
+
* argument. It has a file of its own because it owns every animated value on the
|
|
20
|
+
* panel side, and keeping those in the root would make the root a component that
|
|
21
|
+
* changes for two unrelated reasons.
|
|
22
|
+
*
|
|
23
|
+
* **The row needs no measurement to lay out.** Each page is `w-full shrink-0`
|
|
24
|
+
* against a row whose width is the viewport's, so the very first paint is right.
|
|
25
|
+
* A row sized from a measurement would land one commit after the first layout and
|
|
26
|
+
* show every panel collapsed to nothing in between — the same class of bug as a
|
|
27
|
+
* chat list with no seeded composer height, wrong only sometimes.
|
|
28
|
+
*
|
|
29
|
+
* `pageWidth` is therefore read by the gesture and by the translation, and by
|
|
30
|
+
* nothing that lays anything out.
|
|
31
|
+
*/
|
|
32
|
+
export function TabsPager({ children }: TabsPagerProps): ReactElement {
|
|
33
|
+
const { size, variant } = useTabsPart("Tabs.Pager");
|
|
34
|
+
const { position, pageWidth, panGesture } = useTabsMotionPart("Tabs.Pager");
|
|
35
|
+
|
|
36
|
+
const handleLayout = useCallback(
|
|
37
|
+
(event: LayoutChangeEvent) => {
|
|
38
|
+
pageWidth.value = event.nativeEvent.layout.width;
|
|
39
|
+
},
|
|
40
|
+
[pageWidth]
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const rowStyle = useAnimatedStyle(() => ({
|
|
44
|
+
transform: [{ translateX: resolvePagerTranslate(position.value, pageWidth.value) }],
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
const slots = tabsVariants({ size, variant });
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<Animated.View className={slots.pager()} onLayout={handleLayout}>
|
|
51
|
+
<GestureDetector gesture={panGesture}>
|
|
52
|
+
<Animated.View className={slots.pageRow()} style={rowStyle}>
|
|
53
|
+
{children}
|
|
54
|
+
</Animated.View>
|
|
55
|
+
</GestureDetector>
|
|
56
|
+
</Animated.View>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
TabsPager.displayName = "DelacourUI.Tabs.Pager";
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { type ComponentRef, type ReactElement, type ReactNode, type Ref, useCallback, useEffect } from "react";
|
|
2
|
+
import type { LayoutChangeEvent, ScrollViewProps } from "react-native";
|
|
3
|
+
import Animated, {
|
|
4
|
+
Extrapolation,
|
|
5
|
+
interpolate,
|
|
6
|
+
scrollTo,
|
|
7
|
+
useAnimatedReaction,
|
|
8
|
+
useAnimatedRef,
|
|
9
|
+
useAnimatedScrollHandler,
|
|
10
|
+
} from "react-native-reanimated";
|
|
11
|
+
import { TabsScrollableProvider, useTabsListPart, useTabsMotionPart, useTabsPart } from "./tabs.context";
|
|
12
|
+
import { resolveScrollOffset, TABS_DEFAULT_SCROLL_ALIGN, type TabsScrollAlign, tabsVariants } from "./tabs.variants";
|
|
13
|
+
|
|
14
|
+
export type TabsScrollViewProps = Omit<ScrollViewProps, "children" | "horizontal"> & {
|
|
15
|
+
/**
|
|
16
|
+
* Where the selected trigger is scrolled to when the selection changes.
|
|
17
|
+
*
|
|
18
|
+
* `none` leaves the bar exactly where it is — for a row the caller drives
|
|
19
|
+
* itself. Every alignment is clamped at both ends, so the first and last tabs
|
|
20
|
+
* never scroll the row past its own content.
|
|
21
|
+
*/
|
|
22
|
+
scrollAlign?: TabsScrollAlign;
|
|
23
|
+
className?: string;
|
|
24
|
+
/** Merged after the row's own layout, never instead of it. */
|
|
25
|
+
contentContainerClassName?: string;
|
|
26
|
+
children?: ReactNode;
|
|
27
|
+
ref?: Ref<ComponentRef<typeof Animated.ScrollView>>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The horizontal scroller a bar with more tabs than room puts its row in.
|
|
32
|
+
*
|
|
33
|
+
* `horizontal` is `Omit`ed rather than defaulted. Every measurement in this
|
|
34
|
+
* component is an `x` and a width, so `horizontal={false}` would leave all of that
|
|
35
|
+
* arithmetic intact and silently wrong — a bar that scrolled the wrong way with an
|
|
36
|
+
* indicator that did not move.
|
|
37
|
+
*
|
|
38
|
+
* **It scrolls from the UI thread**, through the package's only `useAnimatedRef`.
|
|
39
|
+
* The reaction interpolates the *fractional* trigger geometry off the same
|
|
40
|
+
* `position` the indicator reads, so the row tracks a finger through a drag rather
|
|
41
|
+
* than jumping once the swipe has settled. Mirroring the offset back to JS and
|
|
42
|
+
* calling `scrollTo` there would be a round trip per frame, which is the one thing
|
|
43
|
+
* this whole component is arranged to avoid.
|
|
44
|
+
*
|
|
45
|
+
* **A hand-scroll always wins, and the flag it sets clears on momentum end rather
|
|
46
|
+
* than on the finger lifting.** A flick leaves the bar coasting after the touch is
|
|
47
|
+
* gone, and retargeting into that coast is a tug-of-war the user feels as the row
|
|
48
|
+
* snapping backwards mid-flight.
|
|
49
|
+
*
|
|
50
|
+
* The caller's `ref` is composed rather than taken: this needs an animated handle
|
|
51
|
+
* of its own, and someone who wants the same handle should not have to give one up.
|
|
52
|
+
*/
|
|
53
|
+
export function TabsScrollView({
|
|
54
|
+
scrollAlign = TABS_DEFAULT_SCROLL_ALIGN,
|
|
55
|
+
className,
|
|
56
|
+
contentContainerClassName,
|
|
57
|
+
children,
|
|
58
|
+
ref,
|
|
59
|
+
onLayout,
|
|
60
|
+
onContentSizeChange,
|
|
61
|
+
...props
|
|
62
|
+
}: TabsScrollViewProps): ReactElement {
|
|
63
|
+
const { size, variant } = useTabsPart("Tabs.ScrollView");
|
|
64
|
+
const { position } = useTabsMotionPart("Tabs.ScrollView");
|
|
65
|
+
const { tracks, scrollX, viewportWidth, contentWidth, isBarDragging } = useTabsListPart("Tabs.ScrollView");
|
|
66
|
+
|
|
67
|
+
const scrollRef = useAnimatedRef<ComponentRef<typeof Animated.ScrollView>>();
|
|
68
|
+
|
|
69
|
+
// The animated ref is the one this ScrollView has to carry — `scrollTo`
|
|
70
|
+
// resolves a view tag through it and a composed callback is not that — so a
|
|
71
|
+
// caller's ref is forwarded after mount rather than merged into the prop.
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!ref) return;
|
|
74
|
+
const node = scrollRef.current;
|
|
75
|
+
if (typeof ref === "function") {
|
|
76
|
+
ref(node);
|
|
77
|
+
return () => {
|
|
78
|
+
ref(null);
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
ref.current = node;
|
|
82
|
+
return () => {
|
|
83
|
+
ref.current = null;
|
|
84
|
+
};
|
|
85
|
+
}, [ref, scrollRef]);
|
|
86
|
+
|
|
87
|
+
const scrollHandler = useAnimatedScrollHandler({
|
|
88
|
+
onScroll: (event) => {
|
|
89
|
+
scrollX.value = event.contentOffset.x;
|
|
90
|
+
viewportWidth.value = event.layoutMeasurement.width;
|
|
91
|
+
contentWidth.value = event.contentSize.width;
|
|
92
|
+
},
|
|
93
|
+
onBeginDrag: () => {
|
|
94
|
+
isBarDragging.value = true;
|
|
95
|
+
},
|
|
96
|
+
onEndDrag: (event) => {
|
|
97
|
+
// Only when the finger left nothing behind it. Anything else is handed to
|
|
98
|
+
// `onMomentumEnd`, which is the callback that knows the bar has stopped.
|
|
99
|
+
if (!event.velocity || event.velocity.x === 0) isBarDragging.value = false;
|
|
100
|
+
},
|
|
101
|
+
onMomentumEnd: () => {
|
|
102
|
+
isBarDragging.value = false;
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Seeded here as well as from `onScroll`, because a bar whose row fits never
|
|
107
|
+
// fires a scroll event at all — and would then auto-scroll against a content
|
|
108
|
+
// width of zero.
|
|
109
|
+
const handleLayout = useCallback(
|
|
110
|
+
(event: LayoutChangeEvent) => {
|
|
111
|
+
onLayout?.(event);
|
|
112
|
+
viewportWidth.value = event.nativeEvent.layout.width;
|
|
113
|
+
},
|
|
114
|
+
[onLayout, viewportWidth]
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const handleContentSizeChange = useCallback(
|
|
118
|
+
(width: number, height: number) => {
|
|
119
|
+
onContentSizeChange?.(width, height);
|
|
120
|
+
contentWidth.value = width;
|
|
121
|
+
},
|
|
122
|
+
[contentWidth, onContentSizeChange]
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
useAnimatedReaction(
|
|
126
|
+
() => position.value,
|
|
127
|
+
(current) => {
|
|
128
|
+
if (scrollAlign === "none" || isBarDragging.value) return;
|
|
129
|
+
const measured = tracks.value;
|
|
130
|
+
if (!measured) return;
|
|
131
|
+
|
|
132
|
+
scrollTo(
|
|
133
|
+
scrollRef,
|
|
134
|
+
resolveScrollOffset({
|
|
135
|
+
align: scrollAlign,
|
|
136
|
+
contentWidth: contentWidth.value,
|
|
137
|
+
currentOffset: scrollX.value,
|
|
138
|
+
viewportWidth: viewportWidth.value,
|
|
139
|
+
width: interpolate(current, measured.index, measured.width, Extrapolation.CLAMP),
|
|
140
|
+
x: interpolate(current, measured.index, measured.x, Extrapolation.CLAMP),
|
|
141
|
+
}),
|
|
142
|
+
0,
|
|
143
|
+
// The spring is already animating `position`; a second animated scroll
|
|
144
|
+
// on top of it would be two easing curves fighting for the same pixels.
|
|
145
|
+
false
|
|
146
|
+
);
|
|
147
|
+
},
|
|
148
|
+
[scrollAlign]
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const slots = tabsVariants({ isScrollable: true, size, variant });
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<TabsScrollableProvider value={true}>
|
|
155
|
+
<Animated.ScrollView
|
|
156
|
+
className={slots.scroll({ className })}
|
|
157
|
+
contentContainerClassName={slots.row({ className: contentContainerClassName })}
|
|
158
|
+
horizontal
|
|
159
|
+
onContentSizeChange={handleContentSizeChange}
|
|
160
|
+
onLayout={handleLayout}
|
|
161
|
+
onScroll={scrollHandler}
|
|
162
|
+
ref={scrollRef}
|
|
163
|
+
showsHorizontalScrollIndicator={false}
|
|
164
|
+
{...props}
|
|
165
|
+
>
|
|
166
|
+
{children}
|
|
167
|
+
</Animated.ScrollView>
|
|
168
|
+
</TabsScrollableProvider>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
TabsScrollView.displayName = "DelacourUI.Tabs.ScrollView";
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type ReactElement, useMemo } from "react";
|
|
2
|
+
import type { ViewProps } from "react-native";
|
|
3
|
+
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
|
4
|
+
import { Separator, type SeparatorProps } from "../separator";
|
|
5
|
+
import { useTabsMotionPart, useTabsPart } from "./tabs.context";
|
|
6
|
+
import { resolveSeparatorIndices, resolveSeparatorOpacity, tabsVariants } from "./tabs.variants";
|
|
7
|
+
|
|
8
|
+
export type TabsSeparatorProps = Omit<ViewProps, "children"> & {
|
|
9
|
+
/**
|
|
10
|
+
* The two tabs this rule sits between.
|
|
11
|
+
*
|
|
12
|
+
* Named rather than inferred: a separator is a leaf and cannot see its
|
|
13
|
+
* siblings, and having the row walk its children to work it out would put the
|
|
14
|
+
* ordering in a second place — the one thing this component keeps in exactly
|
|
15
|
+
* one.
|
|
16
|
+
*/
|
|
17
|
+
betweenValues: readonly [string, string];
|
|
18
|
+
className?: string;
|
|
19
|
+
/** Props for the rule itself, for a caller restyling it in place. */
|
|
20
|
+
separatorProps?: Omit<SeparatorProps, "orientation">;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A hairline between two tabs, which retreats as either one is approached.
|
|
25
|
+
*
|
|
26
|
+
* It renders a `Separator` rather than a second one-pixel rule of its own — the
|
|
27
|
+
* line is already a component, and a second definition of it is a second thing to
|
|
28
|
+
* keep in step. This part is the positioning and the fade.
|
|
29
|
+
*
|
|
30
|
+
* **The fade is driven by the pager's position, not by the settled value.** So the
|
|
31
|
+
* rule withdraws as a finger drags a tab up against it and comes back if the drag
|
|
32
|
+
* is abandoned, rather than blinking away at release — which is the whole reason
|
|
33
|
+
* it is an animation and not a conditional render. `opacity` is not a layout
|
|
34
|
+
* prop, so this costs nothing.
|
|
35
|
+
*
|
|
36
|
+
* A pair naming a tab nothing claims renders at full opacity and warns in
|
|
37
|
+
* development: a separator that vanished for a reason unexplainable from the call
|
|
38
|
+
* site would be worse than one that never fades.
|
|
39
|
+
*/
|
|
40
|
+
export function TabsSeparator({
|
|
41
|
+
betweenValues,
|
|
42
|
+
className,
|
|
43
|
+
separatorProps,
|
|
44
|
+
...props
|
|
45
|
+
}: TabsSeparatorProps): ReactElement {
|
|
46
|
+
const { order, size, variant } = useTabsPart("Tabs.Separator");
|
|
47
|
+
const { position } = useTabsMotionPart("Tabs.Separator");
|
|
48
|
+
|
|
49
|
+
const { left, right } = useMemo(() => resolveSeparatorIndices(order, betweenValues), [order, betweenValues]);
|
|
50
|
+
|
|
51
|
+
if (process.env.NODE_ENV !== "production" && order.length > 0 && (left < 0 || right < 0)) {
|
|
52
|
+
console.warn(
|
|
53
|
+
`Tabs.Separator: betweenValues [${betweenValues[0]}, ${betweenValues[1]}] names a tab this <Tabs> has no panel or trigger for, so the rule will never fade.`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const fadeStyle = useAnimatedStyle(
|
|
58
|
+
() => ({ opacity: resolveSeparatorOpacity(position.value, left, right) }),
|
|
59
|
+
[left, right]
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const slots = tabsVariants({ size, variant });
|
|
63
|
+
|
|
64
|
+
// The slot is positioning and a height, and it goes on the wrapper: a vertical
|
|
65
|
+
// `Separator` is `self-stretch`, so it takes its length from whatever holds it
|
|
66
|
+
// rather than naming one — see ../separator/AGENTS.md.
|
|
67
|
+
return (
|
|
68
|
+
<Animated.View className={slots.separator({ className })} style={fadeStyle} {...props}>
|
|
69
|
+
<Separator orientation="vertical" {...separatorProps} />
|
|
70
|
+
</Animated.View>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
TabsSeparator.displayName = "DelacourUI.Tabs.Separator";
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { Children, type ReactElement, type ReactNode, useCallback, useEffect, useMemo } from "react";
|
|
2
|
+
import type { LayoutChangeEvent } from "react-native";
|
|
3
|
+
import { type IconDefaults, IconDefaultsProvider } from "../icon";
|
|
4
|
+
import { Pressable, type PressableProps } from "../pressable";
|
|
5
|
+
import { TextClassProvider } from "../text/text.context";
|
|
6
|
+
import { resolveTextClass } from "../text/text.variants";
|
|
7
|
+
import {
|
|
8
|
+
type TabsTriggerContextValue,
|
|
9
|
+
TabsTriggerProvider,
|
|
10
|
+
useTabsIsScrollable,
|
|
11
|
+
useTabsListPart,
|
|
12
|
+
useTabsPart,
|
|
13
|
+
} from "./tabs.context";
|
|
14
|
+
import { resolveTabsTriggerState, TABS_FOREGROUND_TOKEN, TABS_LABEL_TEXT_SIZE, tabsVariants } from "./tabs.variants";
|
|
15
|
+
import { TabsLabel } from "./tabs-label";
|
|
16
|
+
|
|
17
|
+
export type TabsTriggerRenderProps = {
|
|
18
|
+
/** What this trigger is called. */
|
|
19
|
+
value: string;
|
|
20
|
+
/** Whether this trigger is the settled selection. */
|
|
21
|
+
isSelected: boolean;
|
|
22
|
+
/** Whether this trigger is unavailable. */
|
|
23
|
+
isDisabled: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type TabsTriggerProps = Omit<PressableProps, "asChild" | "busy" | "children" | "disabled"> & {
|
|
27
|
+
/** Identifies this trigger, and the `Tabs.Content` it selects. Required. */
|
|
28
|
+
value: string;
|
|
29
|
+
/** Wins over the enclosing `Tabs`. `false` opts one trigger out of a disabled bar. */
|
|
30
|
+
isDisabled?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Nodes, or a function called with this trigger's settled state.
|
|
33
|
+
*
|
|
34
|
+
* The render prop is the escape hatch for anything a class cannot express — a
|
|
35
|
+
* count that only shows while selected, a glyph that swaps. Bare strings are
|
|
36
|
+
* wrapped in a `Tabs.Label` automatically.
|
|
37
|
+
*/
|
|
38
|
+
children?: ReactNode | ((props: TabsTriggerRenderProps) => ReactNode);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One tab, and the surface its press is claimed on.
|
|
43
|
+
*
|
|
44
|
+
* **Its `onLayout` is the indicator's geometry.** The frame it reports — an `x`
|
|
45
|
+
* and a width in the row's own space — is what the capsule interpolates over, so
|
|
46
|
+
* there is no table of tab widths anywhere and none could be right: a trigger's
|
|
47
|
+
* width is its label's, which depends on the string and on the OS font scale.
|
|
48
|
+
* That is also half the reason there is no `asChild` here; the other half is that
|
|
49
|
+
* `Pressable`'s own `useAnimatedStyle` already writes `opacity` and `transform`
|
|
50
|
+
* on the node a donated element would become, and two animated styles on one node
|
|
51
|
+
* fight for the same props.
|
|
52
|
+
*
|
|
53
|
+
* **Two defaults differ from a bare `Pressable` and only two.** `feedback` is
|
|
54
|
+
* `fade`, because a wide flat trigger that scales reads as the whole track
|
|
55
|
+
* flexing rather than as one tab responding — `ListGroup.Item`'s reason. `haptic`
|
|
56
|
+
* is `selection`, because a tab change is a selection change. Both are ordinary
|
|
57
|
+
* props, so `haptic={false}` silences it.
|
|
58
|
+
*
|
|
59
|
+
* **`onPress` is forwarded, not swallowed.** The press selects and then the
|
|
60
|
+
* caller's handler runs, so a re-press of the already-active tab still reaches
|
|
61
|
+
* them — which is where a scroll-to-top goes. `shouldEmitTabChange` is what keeps
|
|
62
|
+
* `onValueChange` quiet for it.
|
|
63
|
+
*
|
|
64
|
+
* Icons are composed, never passed as props: the trigger wraps its subtree in an
|
|
65
|
+
* `IconDefaultsProvider` carrying the bar's own glyph step and its variant's
|
|
66
|
+
* foreground, beside a `TextClassProvider` carrying the resolved label treatment.
|
|
67
|
+
* A bare `<Icon>` or `<Text>` inside a trigger therefore needs nothing said at the
|
|
68
|
+
* call site.
|
|
69
|
+
*/
|
|
70
|
+
export function TabsTrigger({
|
|
71
|
+
value,
|
|
72
|
+
isDisabled,
|
|
73
|
+
children,
|
|
74
|
+
className,
|
|
75
|
+
feedback = "fade",
|
|
76
|
+
haptic = "selection",
|
|
77
|
+
onPress,
|
|
78
|
+
onLayout,
|
|
79
|
+
...props
|
|
80
|
+
}: TabsTriggerProps): ReactElement {
|
|
81
|
+
const bar = useTabsPart("Tabs.Trigger");
|
|
82
|
+
const { measure, unmeasure } = useTabsListPart("Tabs.Trigger");
|
|
83
|
+
const isScrollable = useTabsIsScrollable();
|
|
84
|
+
|
|
85
|
+
const { isDisabled: settledIsDisabled } = resolveTabsTriggerState({ own: isDisabled, root: bar.isDisabled });
|
|
86
|
+
const isSelected = bar.value === value;
|
|
87
|
+
const isVisuallySelected = bar.order[bar.visualIndex] === value;
|
|
88
|
+
const index = bar.order.indexOf(value);
|
|
89
|
+
|
|
90
|
+
const { registerValue } = bar;
|
|
91
|
+
useEffect(() => registerValue(value), [registerValue, value]);
|
|
92
|
+
|
|
93
|
+
useEffect(() => () => unmeasure(value), [unmeasure, value]);
|
|
94
|
+
|
|
95
|
+
const handleLayout = useCallback(
|
|
96
|
+
(event: LayoutChangeEvent) => {
|
|
97
|
+
// The caller's runs first and always, the way `Screen.Navbar` forwards its
|
|
98
|
+
// own — a component that measured itself and swallowed the event would
|
|
99
|
+
// take a prop away that has nothing to do with the measurement.
|
|
100
|
+
onLayout?.(event);
|
|
101
|
+
measure(value, { width: event.nativeEvent.layout.width, x: event.nativeEvent.layout.x });
|
|
102
|
+
},
|
|
103
|
+
[measure, onLayout, value]
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const handlePress = useCallback(() => {
|
|
107
|
+
bar.select(value);
|
|
108
|
+
onPress?.();
|
|
109
|
+
}, [bar, onPress, value]);
|
|
110
|
+
|
|
111
|
+
const context = useMemo<TabsTriggerContextValue>(
|
|
112
|
+
() => ({ index, isDisabled: settledIsDisabled, isSelected, isVisuallySelected, value }),
|
|
113
|
+
[index, settledIsDisabled, isSelected, isVisuallySelected, value]
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const slots = tabsVariants({
|
|
117
|
+
isDisabled: settledIsDisabled,
|
|
118
|
+
isScrollable,
|
|
119
|
+
size: bar.size,
|
|
120
|
+
variant: bar.variant,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const iconClassName = slots.icon();
|
|
124
|
+
const iconDefaults = useMemo<IconDefaults>(
|
|
125
|
+
() => ({
|
|
126
|
+
className: iconClassName,
|
|
127
|
+
color: isVisuallySelected
|
|
128
|
+
? TABS_FOREGROUND_TOKEN[bar.variant].selected
|
|
129
|
+
: TABS_FOREGROUND_TOKEN[bar.variant].unselected,
|
|
130
|
+
}),
|
|
131
|
+
[bar.variant, iconClassName, isVisuallySelected]
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// One treatment covers the whole subtree — a tab has a label and no description
|
|
135
|
+
// part — which is the condition for publishing into the cascade.
|
|
136
|
+
//
|
|
137
|
+
// It carries no colour, because the `label` slot no longer names one: the
|
|
138
|
+
// label's colour fades, and a class here would be a second source for it. A
|
|
139
|
+
// bare `<Text>` composed into a trigger therefore takes the page colour; reach
|
|
140
|
+
// for `Tabs.Label` when it should read as part of the tab.
|
|
141
|
+
const labelClassName = slots.label();
|
|
142
|
+
const labelClass = useMemo(
|
|
143
|
+
() => resolveTextClass({ className: labelClassName, size: TABS_LABEL_TEXT_SIZE[bar.size], variant: "label" }),
|
|
144
|
+
[bar.size, labelClassName]
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const content = useMemo(() => {
|
|
148
|
+
if (typeof children === "function") {
|
|
149
|
+
return children({ isDisabled: settledIsDisabled, isSelected, value });
|
|
150
|
+
}
|
|
151
|
+
return wrapTextChildren(children);
|
|
152
|
+
}, [children, settledIsDisabled, isSelected, value]);
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<TabsTriggerProvider value={context}>
|
|
156
|
+
<Pressable
|
|
157
|
+
accessibilityRole="tab"
|
|
158
|
+
accessibilityState={{ disabled: settledIsDisabled, selected: isSelected }}
|
|
159
|
+
className={slots.trigger({ className })}
|
|
160
|
+
disabled={settledIsDisabled}
|
|
161
|
+
feedback={feedback}
|
|
162
|
+
haptic={haptic}
|
|
163
|
+
onLayout={handleLayout}
|
|
164
|
+
onPress={handlePress}
|
|
165
|
+
{...props}
|
|
166
|
+
>
|
|
167
|
+
<IconDefaultsProvider value={iconDefaults}>
|
|
168
|
+
<TextClassProvider value={labelClass}>{content}</TextClassProvider>
|
|
169
|
+
</IconDefaultsProvider>
|
|
170
|
+
</Pressable>
|
|
171
|
+
</TabsTriggerProvider>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
TabsTrigger.displayName = "DelacourUI.Tabs.Trigger";
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Wraps bare text in a `Tabs.Label`.
|
|
178
|
+
*
|
|
179
|
+
* React Native cannot render a string outside a `<Text>`, so `<Tabs.Trigger
|
|
180
|
+
* value="a">All</Tabs.Trigger>` would otherwise crash. Consecutive strings and
|
|
181
|
+
* numbers collapse into one label rather than one each — `Tab {n}` is a single
|
|
182
|
+
* piece of text, and wrapping the parts separately would space them apart by the
|
|
183
|
+
* trigger's own gap.
|
|
184
|
+
*
|
|
185
|
+
* Lives here rather than with the label: it is the trigger that wraps its own
|
|
186
|
+
* children, and importing it from the part would close a cycle. See AGENTS.md.
|
|
187
|
+
*/
|
|
188
|
+
function wrapTextChildren(children: ReactNode): ReactNode {
|
|
189
|
+
const items = Children.toArray(children);
|
|
190
|
+
const output: ReactNode[] = [];
|
|
191
|
+
let run: (string | number)[] = [];
|
|
192
|
+
|
|
193
|
+
const flushRun = () => {
|
|
194
|
+
if (run.length === 0) return;
|
|
195
|
+
output.push(<TabsLabel key={`label-${output.length}`}>{run.join("")}</TabsLabel>);
|
|
196
|
+
run = [];
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
for (const child of items) {
|
|
200
|
+
if (typeof child === "string" || typeof child === "number") {
|
|
201
|
+
run.push(child);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
flushRun();
|
|
205
|
+
output.push(child);
|
|
206
|
+
}
|
|
207
|
+
flushRun();
|
|
208
|
+
|
|
209
|
+
return output;
|
|
210
|
+
}
|