Skip to content
Merged
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
10 changes: 10 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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: "",
Expand Down
6 changes: 3 additions & 3 deletions apps/mobile/src/components/Button.android.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -64,7 +64,7 @@ export function Button(props: ButtonProps) {
<View importantForAccessibility="no-hide-descendants">
<Host
matchContents={props.fullWidth ? { vertical: true } : true}
colorScheme={themeAppearance}
colorScheme={colorScheme}
ignoreSafeAreaKeyboardInsets
style={props.fullWidth ? { width: "100%" } : { alignSelf: "flex-start" }}
>
Expand Down
90 changes: 90 additions & 0 deletions apps/mobile/src/components/Chip.tsx
Original file line number Diff line number Diff line change
@@ -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 ? (
<SymbolView name={props.icon} size={15} tintColorClassName={iconColor} />
) : null}
<AppText
numberOfLines={small ? undefined : 1}
className={cn(
"shrink",
!small && "max-w-[180px]",
small ? "text-2xs" : "text-sm font-t3-medium",
props.selected ? "text-primary-foreground" : "text-foreground-muted",
)}
>
{props.label}
</AppText>
{props.trailingIcon ? (
<SymbolView name={props.trailingIcon} size={11} tintColorClassName={iconColor} />
) : null}
</>
);
const body =
props.glass && NATIVE_LIQUID_GLASS_SUPPORTED ? (
<ChipGlass
isInteractive
colorScheme={props.colorScheme}
tintColorClassName={props.selected ? "accent-primary" : undefined}
className={layout}
>
{content}
</ChipGlass>
) : (
<View
className={cn(
layout,
props.selected ? "bg-primary" : small ? "bg-grouped-card" : "bg-subtle",
)}
>
{content}
</View>
);
return props.onPress ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={props.accessibilityLabel ?? props.label}
accessibilityState={{ selected: props.selected, disabled: Boolean(props.disabled) }}
disabled={props.disabled}
onPress={props.onPress}
hitSlop={6}
className={props.disabled ? "opacity-45" : "active:opacity-70"}
>
{body}
</Pressable>
) : (
body
);
}
9 changes: 2 additions & 7 deletions apps/mobile/src/components/ErrorBanner.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View className="rounded-2xl border border-danger-border bg-danger px-3.5 py-3">
<Text className="font-t3-medium text-sm text-danger-foreground">{props.message}</Text>
</View>
);
return <Notice tone="danger" message={props.message} />;
}
6 changes: 3 additions & 3 deletions apps/mobile/src/components/MaterialIconButton.android.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 =
Expand Down Expand Up @@ -58,7 +58,7 @@ export function MaterialIconButton(props: {
>
<View importantForAccessibility="no-hide-descendants">
<Host
colorScheme={themeAppearance}
colorScheme={colorScheme}
ignoreSafeAreaKeyboardInsets
style={{ width: buttonSize, height: buttonSize }}
>
Expand Down
4 changes: 3 additions & 1 deletion apps/mobile/src/components/MaterialSearchField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<TextInputInstance | null>;
readonly accessibilityLabel: string;
readonly clearAccessibilityLabel: string;
Expand All @@ -37,7 +39,7 @@ export function MaterialSearchField({
<TextInput
ref={inputRef}
accessibilityLabel={accessibilityLabel}
autoFocus
autoFocus={autoFocus}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="search"
Expand Down
6 changes: 3 additions & 3 deletions apps/mobile/src/components/MaterialSwitch.android.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { Host, Switch as ComposeSwitch } from "@expo/ui/jetpack-compose";
import { View } from "react-native";
import type { ThemedSwitchProps } from "./MaterialSwitch.types";

import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
import { useControlTheme } from "./useControlTheme";

/** Material's native switch, with the same palette and accessibility contract as our RN controls. */
export function MaterialSwitch(props: ThemedSwitchProps) {
const { themeAppearance, themeVariables: colors } = useAppearancePreferences();
const { colorScheme, colors } = useControlTheme();
const toggle = () => {
if (!props.disabled) props.onValueChange?.(!props.value);
};
Expand All @@ -25,7 +25,7 @@ export function MaterialSwitch(props: ThemedSwitchProps) {
>
<View importantForAccessibility="no-hide-descendants">
<Host
colorScheme={themeAppearance}
colorScheme={colorScheme}
ignoreSafeAreaKeyboardInsets
style={{ width: 52, height: 48 }}
>
Expand Down
85 changes: 85 additions & 0 deletions apps/mobile/src/components/Notice.tsx
Original file line number Diff line number Diff line change
@@ -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 = (
<View className="flex-row items-start gap-3">
{props.icon ? (
<View className="pt-0.5">
<SymbolView
name={props.icon}
size={16}
tintColorClassName={
danger || props.iconTone === "danger"
? "accent-danger-foreground"
: "accent-icon-muted"
}
/>
</View>
) : null}
<AppText
selectable
accessibilityLiveRegion="polite"
className={cn(
"min-w-0 flex-1 text-sm",
danger ? "text-danger-foreground" : "text-foreground",
!props.floating && "font-t3-medium",
)}
>
{props.message}
</AppText>
{props.onDismiss ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={props.dismissLabel ?? "Dismiss message"}
onPress={props.onDismiss}
hitSlop={10}
className="min-h-6 min-w-6 items-center justify-center active:opacity-60"
>
<SymbolView
name="xmark"
size={14}
tintColorClassName={danger ? "accent-danger-foreground" : "accent-icon-muted"}
/>
</Pressable>
) : null}
</View>
);
if (props.floating && Platform.OS === "ios")
return (
<GlassSurface className="p-4" style={{ borderRadius: 20 }}>
{content}
</GlassSurface>
);
return (
<View
className={cn(
"rounded-2xl border px-3.5 py-3",
danger ? "border-danger-border bg-danger" : "border-border bg-card",
props.floating && "p-4",
)}
>
{content}
</View>
);
}
44 changes: 44 additions & 0 deletions apps/mobile/src/components/NumberField.android.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View className="shrink-0 flex-row items-center">
{/* Match the 32dp switch track while retaining 48dp button touch targets. */}
<View
pointerEvents="none"
className="absolute inset-x-0 rounded-full bg-subtle"
style={{ height: 32 }}
/>
<MaterialIconButton
icon="minus"
accessibilityLabel={`Decrease ${props.label}`}
disabled={props.disabled || props.value <= props.min}
onPress={() => adjust(-1)}
/>
<AppText
className="min-w-8 text-center text-base"
style={{ fontVariant: ["tabular-nums"] }}
accessibilityLabel={`${props.label}: ${props.formatValue?.(props.value) ?? props.value}`}
accessibilityLiveRegion="polite"
>
{props.value}
</AppText>
<MaterialIconButton
icon="plus"
accessibilityLabel={`Increase ${props.label}`}
disabled={props.disabled || props.value >= props.max}
onPress={() => adjust(1)}
/>
</View>
);
}
Loading
Loading