diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 4dfa3f3a9856..8a16c17bc2ba 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -1,3 +1,4 @@ +import { ComponentCatalogScreen } from "./features/development/ComponentCatalogScreen"; import { PullRequestGuideStopScreen } from "./features/pull-requests/guide/PullRequestGuideScreen"; import { PullRequestInlineCommentScreen } from "./features/pull-requests/PullRequestInlineCommentScreen"; import { PullRequestDiscussionScreen } from "./features/pull-requests/PullRequestDiscussionScreen"; @@ -716,6 +717,15 @@ const RootStackConfig = createWorkspaceStackNavigator({ headerShown: false, }, screens: { + ...(__DEV__ + ? { + ComponentCatalog: createNativeStackScreen({ + screen: ComponentCatalogScreen, + linking: "dev/components", + options: { title: "UI components" }, + }), + } + : {}), Home: createNativeStackScreen({ screen: WorkspaceRootScreen, linking: "", diff --git a/apps/mobile/src/components/Button.android.tsx b/apps/mobile/src/components/Button.android.tsx index 1d8919034219..7c0fcf45bcbc 100644 --- a/apps/mobile/src/components/Button.android.tsx +++ b/apps/mobile/src/components/Button.android.tsx @@ -13,13 +13,13 @@ import { import { defaultMinSize, fillMaxWidth, size } from "@expo/ui/jetpack-compose/modifiers"; import { View } from "react-native"; -import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { useControlTheme } from "./useControlTheme"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; import type { ButtonProps } from "./Button"; import { useAndroidControlSizing } from "./useAndroidControlSizing"; export function Button(props: ButtonProps) { - const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); + const { colorScheme, colors } = useControlTheme(); const typography = useScaledTextRole("footnote"); const { scale, mediumIconSize } = useAndroidControlSizing(); const tone = props.tone ?? "secondary"; @@ -64,7 +64,7 @@ export function Button(props: ButtonProps) { diff --git a/apps/mobile/src/components/Chip.tsx b/apps/mobile/src/components/Chip.tsx new file mode 100644 index 000000000000..9f46e7ffd3b1 --- /dev/null +++ b/apps/mobile/src/components/Chip.tsx @@ -0,0 +1,90 @@ +import { GlassView } from "expo-glass-effect"; +import { Pressable, View } from "react-native"; +import { withUniwind } from "uniwind"; + +import { cn } from "../lib/cn"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../native/native-glass"; +import { AppText } from "./AppText"; +import { SymbolView, type AppSymbolName } from "./AppSymbol"; + +const ChipGlass = withUniwind(GlassView, { + style: { fromClassName: "className" }, + tintColor: { fromClassName: "tintColorClassName", styleProperty: "accentColor" }, +}); + +/** Compact labels and filter triggers. Menu owners can wrap the non-pressable form. */ +export function Chip(props: { + readonly label: string; + readonly selected?: boolean; + readonly icon?: AppSymbolName; + readonly trailingIcon?: AppSymbolName; + readonly size?: "small" | "default"; + readonly glass?: boolean; + readonly colorScheme?: "light" | "dark"; + readonly onPress?: () => void; + readonly accessibilityLabel?: string; + readonly disabled?: boolean; +}) { + const small = props.size === "small"; + const layout = cn( + "flex-row items-center gap-1.5 rounded-2xl px-3", + small ? "py-1" : "min-h-8 py-1.5", + ); + const iconColor = props.selected ? "accent-primary-foreground" : "accent-icon-muted"; + const content = ( + <> + {props.icon ? ( + + ) : null} + + {props.label} + + {props.trailingIcon ? ( + + ) : null} + + ); + const body = + props.glass && NATIVE_LIQUID_GLASS_SUPPORTED ? ( + + {content} + + ) : ( + + {content} + + ); + return props.onPress ? ( + + {body} + + ) : ( + body + ); +} diff --git a/apps/mobile/src/components/ErrorBanner.tsx b/apps/mobile/src/components/ErrorBanner.tsx index 38f85de195b5..c049fccc4213 100644 --- a/apps/mobile/src/components/ErrorBanner.tsx +++ b/apps/mobile/src/components/ErrorBanner.tsx @@ -1,10 +1,5 @@ -import { View } from "react-native"; +import { Notice } from "./Notice"; -import { AppText as Text } from "./AppText"; export function ErrorBanner(props: { readonly message: string }) { - return ( - - {props.message} - - ); + return ; } diff --git a/apps/mobile/src/components/MaterialIconButton.android.tsx b/apps/mobile/src/components/MaterialIconButton.android.tsx index 64387e3a4f48..0c7e6daa5e88 100644 --- a/apps/mobile/src/components/MaterialIconButton.android.tsx +++ b/apps/mobile/src/components/MaterialIconButton.android.tsx @@ -7,7 +7,7 @@ import { import { size } from "@expo/ui/jetpack-compose/modifiers"; import { View } from "react-native"; -import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { useControlTheme } from "./useControlTheme"; import { useAndroidControlSizing } from "./useAndroidControlSizing"; import { SymbolView, type AppSymbolName } from "./AppSymbol"; @@ -20,7 +20,7 @@ export function MaterialIconButton(props: { readonly variant?: "standard" | "primary" | "tonal" | "danger"; readonly tintColorClassName?: string; }) { - const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); + const { colorScheme, colors } = useControlTheme(); const { iconSize, buttonSize } = useAndroidControlSizing(); const variant = props.variant ?? "standard"; const Component = @@ -58,7 +58,7 @@ export function MaterialIconButton(props: { > diff --git a/apps/mobile/src/components/MaterialSearchField.tsx b/apps/mobile/src/components/MaterialSearchField.tsx index 639249313bfd..f32ac5e86d59 100644 --- a/apps/mobile/src/components/MaterialSearchField.tsx +++ b/apps/mobile/src/components/MaterialSearchField.tsx @@ -6,12 +6,14 @@ import { useAndroidControlSizing } from "./useAndroidControlSizing"; export function MaterialSearchField({ inputRef, + autoFocus = true, accessibilityLabel, clearAccessibilityLabel, placeholder, value, onChangeText, }: { + readonly autoFocus?: boolean; readonly inputRef: RefObject; readonly accessibilityLabel: string; readonly clearAccessibilityLabel: string; @@ -37,7 +39,7 @@ export function MaterialSearchField({ { if (!props.disabled) props.onValueChange?.(!props.value); }; @@ -25,7 +25,7 @@ export function MaterialSwitch(props: ThemedSwitchProps) { > diff --git a/apps/mobile/src/components/Notice.tsx b/apps/mobile/src/components/Notice.tsx new file mode 100644 index 000000000000..d6cf26d40c36 --- /dev/null +++ b/apps/mobile/src/components/Notice.tsx @@ -0,0 +1,85 @@ +import { useEffect } from "react"; +import { AccessibilityInfo, Platform, Pressable, View } from "react-native"; + +import { cn } from "../lib/cn"; +import { AppText } from "./AppText"; +import { SymbolView, type AppSymbolName } from "./AppSymbol"; +import { GlassSurface } from "./GlassSurface"; + +/** Inline feedback or a dismissible notice positioned by its owning screen. */ +export function Notice(props: { + readonly message: string; + readonly tone?: "default" | "danger"; + readonly icon?: AppSymbolName; + readonly iconTone?: "default" | "danger"; + readonly floating?: boolean; + readonly onDismiss?: () => void; + readonly dismissLabel?: string; +}) { + const danger = props.tone === "danger"; + useEffect(() => { + // Live regions reach TalkBack; VoiceOver needs an explicit announcement. + if (Platform.OS === "ios") AccessibilityInfo.announceForAccessibility(props.message); + }, [props.message]); + + const content = ( + + {props.icon ? ( + + + + ) : null} + + {props.message} + + {props.onDismiss ? ( + + + + ) : null} + + ); + if (props.floating && Platform.OS === "ios") + return ( + + {content} + + ); + return ( + + {content} + + ); +} diff --git a/apps/mobile/src/components/NumberField.android.tsx b/apps/mobile/src/components/NumberField.android.tsx new file mode 100644 index 000000000000..23095ee862fb --- /dev/null +++ b/apps/mobile/src/components/NumberField.android.tsx @@ -0,0 +1,44 @@ +import { View } from "react-native"; + +import { AppText } from "./AppText"; +import { MaterialIconButton } from "./MaterialIconButton"; +import type { NumberFieldProps } from "./NumberField"; + +export function NumberField(props: NumberFieldProps) { + const adjust = (amount: number) => { + if (props.disabled) return; + const next = Math.max(props.min, Math.min(props.max, props.value + amount)); + if (next !== props.value) props.onValueChange(next); + }; + + return ( + + {/* Match the 32dp switch track while retaining 48dp button touch targets. */} + + adjust(-1)} + /> + + {props.value} + + = props.max} + onPress={() => adjust(1)} + /> + + ); +} diff --git a/apps/mobile/src/features/settings/components/AutoSettleDaysField.ios.tsx b/apps/mobile/src/components/NumberField.ios.tsx similarity index 70% rename from apps/mobile/src/features/settings/components/AutoSettleDaysField.ios.tsx rename to apps/mobile/src/components/NumberField.ios.tsx index d7734f5a923c..958698b65a99 100644 --- a/apps/mobile/src/features/settings/components/AutoSettleDaysField.ios.tsx +++ b/apps/mobile/src/components/NumberField.ios.tsx @@ -11,26 +11,18 @@ import { presentationBackground, tag, } from "@expo/ui/swift-ui/modifiers"; -import { - MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, - MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, -} from "@t3tools/contracts"; import { useState } from "react"; -import { useAppearancePreferences } from "../appearance/AppearancePreferencesProvider"; -import type { AutoSettleDaysFieldProps } from "./AutoSettleDaysField"; - -const days = Array.from( - { length: MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS - MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS + 1 }, - (_, index) => MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS + index, -); +import { useControlTheme } from "./useControlTheme"; +import type { NumberFieldProps } from "./NumberField"; -export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { - const { themeAppearance, themeVariables: colors, appearance } = useAppearancePreferences(); +export function NumberField(props: NumberFieldProps) { + const { colorScheme, colors, fontSize } = useControlTheme(); + const values = Array.from({ length: props.max - props.min + 1 }, (_, index) => props.min + index); const [open, setOpen] = useState(false); const [draft, setDraft] = useState(props.value); return ( - +