Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions apps/mobile/src/components/ui/Tabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { GlassView } from "expo-glass-effect";
import { useEffect, useRef, useState, type ComponentRef } from "react";
import { Pressable, ScrollView, View } from "react-native";
import Animated, { useAnimatedStyle, useReducedMotion, withTiming } from "react-native-reanimated";
import { withUniwind } from "uniwind";

import { cn } from "../../lib/cn";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { useAppearancePreferences } from "../../features/settings/appearance/AppearancePreferencesProvider";
import { AppText } from "./AppText";

const TabGlass = withUniwind(GlassView, { style: { fromClassName: "className" } });

export interface TabsProps<Value extends string> {
readonly options: readonly {
readonly value: Value;
readonly label: string;
}[];
readonly selected: Value;
readonly onSelect: (value: Value) => void;
readonly className?: string;
}

/** Section navigation. Value pickers use SegmentedControl. */
export function Tabs<Value extends string>(props: TabsProps<Value>) {
const { themeAppearance } = useAppearancePreferences();
const reduceMotion = useReducedMotion();
const scrollView = useRef<ComponentRef<typeof ScrollView>>(null);
const [frames, setFrames] = useState<Partial<Record<Value, { x: number; width: number }>>>({});
const frame = frames[props.selected];
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- keeps the selected tab visible in the native scroll viewport
useEffect(() => {
if (frame) scrollView.current?.scrollTo({ x: frame.x, animated: false });
}, [frame]);
const duration = reduceMotion ? 0 : 220;
const indicatorStyle = useAnimatedStyle(() => ({
width: withTiming(frame?.width ?? 0, { duration }),
transform: [{ translateX: withTiming(frame?.x ?? 0, { duration }) }],
}));

return (
<ScrollView
ref={scrollView}
horizontal
showsHorizontalScrollIndicator={false}
className={cn("grow-0", props.className)}
>
Comment thread
jakeleventhal marked this conversation as resolved.
Resolved
<View accessibilityRole="tablist" className="flex-row">
{frame ? (
<Animated.View
pointerEvents="none"
className="absolute bottom-0 left-0 top-0 rounded-full border border-border-subtle"
style={indicatorStyle}
>
{NATIVE_LIQUID_GLASS_SUPPORTED ? (
<TabGlass
className="flex-1 rounded-full"
colorScheme={themeAppearance === "dark" ? "dark" : "light"}
/>
) : (
<View className="flex-1 rounded-full bg-grouped-card" />
)}
</Animated.View>
) : null}
{props.options.map((option) => {
const selected = option.value === props.selected;
return (
<Pressable
key={option.value}
accessibilityRole="tab"
accessibilityLabel={option.label}
accessibilityState={{ selected }}
onPress={() => props.onSelect(option.value)}
onLayout={({ nativeEvent: { layout } }) =>
setFrames((current) =>
current[option.value]?.x === layout.x &&
current[option.value]?.width === layout.width
? current
: { ...current, [option.value]: { x: layout.x, width: layout.width } },
)
}
className="min-h-11 shrink-0 flex-row items-center gap-1.5 rounded-full px-3.5 active:opacity-70"
>
<AppText
textRole="footnote"
className={cn(
"font-t3-semibold",
selected ? "text-foreground" : "text-foreground-muted",
)}
>
{option.label}
</AppText>
</Pressable>
);
})}
</View>
</ScrollView>
);
}
5 changes: 3 additions & 2 deletions apps/mobile/src/components/ui/segmentedControlColors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,11 @@ export function segmentedControlColors(
colors["--color-grouped-card"],
)
: flattenThemeColor(themeColorWithAlpha(accent, dark ? 0.1 : 0.07), colors["--color-screen"]);
let selectedColor = colors["--color-secondary"];
// The thumb must lift off the track: --color-secondary nearly matches it in light themes.
let selectedColor = dark && card ? colors["--color-secondary"] : colors["--color-card"];
if (card && !dark) selectedColor = colors["--color-screen"];
if (dark && (!card || themeId === "t3-code")) {
selectedColor = flattenThemeColor(themeColorWithAlpha(accent, 0.32), colors["--color-screen"]);
selectedColor = flattenThemeColor(themeColorWithAlpha(accent, 0.2), colors["--color-screen"]);
}
let selectedTextColor =
colors[!card && dark ? "--color-foreground" : "--color-secondary-foreground"];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { NumberField } from "../../components/ui/NumberField";
import { AppBar } from "../../components/ui/AppBar";
import { ScreenScrollView } from "../../components/ui/ScreenScrollView";
import { SegmentedControl } from "../../components/ui/SegmentedControl";
import { Tabs } from "../../components/ui/Tabs";
import { SliderRow } from "../../components/ui/SliderRow";
import * as Dialog from "../../components/ui/Dialog";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
Expand Down Expand Up @@ -89,15 +90,14 @@ export function ComponentCatalogScreen() {
size="compact"
/>
</View>
<SegmentedControl
<Tabs
options={[
{ value: "controls", label: "Controls" },
{ value: "forms", label: "Forms" },
{ value: "feedback", label: "Feedback" },
]}
selected={page}
onSelect={setPage}
size="compact"
/>
{page === "controls" ? (
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ import { AndroidWorkspaceSidebarButton } from "../layout/workspace-sidebar-toolb
import { useWorkspaceSidebarHeaderItem } from "../layout/useWorkspaceSidebarHeaderItem";
import { useAndroidWorkspaceBack } from "../workspace/useAndroidWorkspaceNavigation";
import { EmptyState } from "../../components/ui/EmptyState";
import { SegmentedControl } from "../../components/ui/SegmentedControl";
import { Tabs } from "../../components/ui/Tabs";
import { SymbolView } from "../../components/ui/AppSymbol";
import { RelativeTime } from "../../components/RelativeTime";
import { NativeStackScreenOptions } from "../../native/StackHeader";
Expand Down Expand Up @@ -873,10 +873,8 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
}}
>
<PullRequestIdentity pr={identity} params={params} />
{/* UIKit draws the tapped control's glass lens outside the hidden
tab's opacity, so only the visible tab mounts the control. */}
{Platform.OS !== "ios" || section === tab ? (
<SegmentedControl
<Tabs
options={[
{ value: "overview", label: "Overview" },
...(guidesEnabled
Expand All @@ -886,11 +884,10 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
]}
selected={section}
onSelect={selectSection}
role="tab"
size="compact"
className="-mx-3.5"
/>
) : (
<View style={{ height: 32 }} />
<View style={{ height: 44 }} />
)}
</View>
) : null}
Expand Down
21 changes: 4 additions & 17 deletions apps/mobile/src/features/usage/UsageRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import Animated, { FadeIn, ReduceMotion } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { SegmentedControl } from "../../components/ui/SegmentedControl";
import { Tabs } from "../../components/ui/Tabs";
import { useReduceMotionEnabled } from "../../lib/appState";
import { AppText as Text } from "../../components/ui/AppText";
import { ProviderIcon } from "../../components/ProviderIcon";
Expand Down Expand Up @@ -297,9 +298,8 @@ function UsageScreen(props: {
<View className="w-full gap-6 self-center" style={{ maxWidth: MAX_CONTENT_WIDTH }}>
{wide ? (
<View className="flex-row items-center gap-3">
{showingLimits ? (
<View className="flex-1" />
) : (
<Tabs options={VIEW_OPTIONS} selected={view} onSelect={setView} className="-ms-3.5" />
{showingLimits ? null : (
<SegmentedControl
options={WINDOW_OPTIONS}
selected={windowDays}
Expand All @@ -308,22 +308,9 @@ function UsageScreen(props: {
className="flex-1"
/>
)}
<SegmentedControl
options={VIEW_OPTIONS}
selected={view}
onSelect={setView}
size="compact"
role="tab"
className="w-60"
/>
</View>
) : (
<SegmentedControl
options={VIEW_OPTIONS}
selected={view}
onSelect={setView}
role="tab"
/>
<Tabs options={VIEW_OPTIONS} selected={view} onSelect={setView} className="-mx-3.5" />
)}
<Animated.View
key={showingLimits ? "limits" : "usage"}
Expand Down
Loading