From f105a3eb06a1708fbeeb88bde9141abc14c51890 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Fedyna?= Date: Thu, 6 Aug 2026 14:39:10 +0200 Subject: [PATCH 1/5] feat: move preferences from drawer to modal --- example/src/DrawerItems.tsx | 293 ------------------ example/src/Examples/CardExample.tsx | 12 +- example/src/Examples/SearchbarExample.tsx | 12 +- example/src/Examples/SnackbarExample.tsx | 8 +- .../{ => Preferences}/PreferencesContext.tsx | 11 +- example/src/Preferences/PreferencesModal.tsx | 199 ++++++++++++ example/src/Preferences/setupPreferences.ts | 173 +++++++++++ example/src/Preferences/usePreferences.tsx | 11 + example/src/RootNavigator.tsx | 43 ++- example/src/Samples/MainSample.tsx | 3 + example/src/index.tsx | 198 +----------- 11 files changed, 454 insertions(+), 509 deletions(-) delete mode 100644 example/src/DrawerItems.tsx rename example/src/{ => Preferences}/PreferencesContext.tsx (64%) create mode 100644 example/src/Preferences/PreferencesModal.tsx create mode 100644 example/src/Preferences/setupPreferences.ts create mode 100644 example/src/Preferences/usePreferences.tsx create mode 100644 example/src/Samples/MainSample.tsx diff --git a/example/src/DrawerItems.tsx b/example/src/DrawerItems.tsx deleted file mode 100644 index 94afa3136c..0000000000 --- a/example/src/DrawerItems.tsx +++ /dev/null @@ -1,293 +0,0 @@ -import * as React from 'react'; -import { Platform, StyleSheet, View } from 'react-native'; -import type { ColorValue } from 'react-native'; - -import { DrawerContentScrollView } from '@react-navigation/drawer'; -import Constants, { ExecutionEnvironment } from 'expo-constants'; -import { - Badge, - Button, - Dialog, - Drawer, - Palette, - Portal, - Switch, - Text, - TouchableRipple, - useTheme, -} from 'react-native-paper'; - -import { dynamicThemeSupported, isWeb } from '../utils'; -import { PreferencesContext } from './PreferencesContext'; - -const DrawerItemsData = [ - { - label: 'Inbox', - icon: 'inbox', - key: 0, - right: () => 44, - }, - { - label: 'Starred', - icon: 'star', - key: 1, - right: ({ color }: { color: ColorValue }) => ( - - ), - }, - { label: 'Sent mail', icon: 'send', key: 2 }, - { label: 'Colored label', icon: 'palette', key: 3 }, - { - label: 'A very long title that will be truncated', - icon: 'delete', - key: 4, - right: () => , - }, -]; - -const DrawerCollapsedItemsData = [ - { - label: 'Inbox', - focusedIcon: 'inbox', - unfocusedIcon: 'inbox-outline', - key: 0, - badge: 44, - }, - { - label: 'Starred', - focusedIcon: 'star', - unfocusedIcon: 'star-outline', - key: 1, - }, - { - label: 'Sent mail', - focusedIcon: 'send', - unfocusedIcon: 'send-outline', - key: 2, - }, - { - label: 'A very long title that will be truncated', - focusedIcon: 'delete', - unfocusedIcon: 'delete-outline', - key: 3, - }, - { - label: 'Full width', - focusedIcon: 'arrow-all', - key: 4, - }, - { - focusedIcon: 'bell', - unfocusedIcon: 'bell-outline', - key: 5, - badge: true, - }, -]; - -function DrawerItems() { - const [drawerItemIndex, setDrawerItemIndex] = React.useState(0); - const [showRTLDialog, setShowRTLDialog] = React.useState(false); - const preferences = React.useContext(PreferencesContext); - - const _setDrawerItem = (index: number) => setDrawerItemIndex(index); - - const { colors } = useTheme(); - const isIOS = Platform.OS === 'ios'; - const expoGoExecution = - Constants.executionEnvironment === ExecutionEnvironment.StoreClient; - - if (!preferences) throw new Error('PreferencesContext not provided'); - - const { - toggleShouldUseDynamicTheme, - toggleTheme, - toggleRtl: toggleRTL, - toggleCollapsed, - toggleCustomFont, - toggleRippleEffect, - customFontLoaded, - rippleEffectEnabled, - collapsed, - rtl: isRTL, - theme: { dark: isDarkTheme }, - shouldUseDynamicTheme, - } = preferences; - - const _handleToggleRTL = () => { - if (!isWeb && expoGoExecution) { - setShowRTLDialog(true); - return; - } - - toggleRTL(); - }; - - const _handleDismissRTLDialog = () => { - setShowRTLDialog(false); - }; - - const coloredLabelTheme = { - colors: { - secondaryContainer: Palette.tertiary80, - onSecondaryContainer: Palette.tertiary20, - }, - }; - - return ( - - {collapsed && ( - - {DrawerCollapsedItemsData.map((props, index) => ( - { - _setDrawerItem(index); - index === 4 && toggleCollapsed(); - }} - /> - ))} - - )} - {!collapsed && ( - <> - - {DrawerItemsData.map((props, index) => ( - _setDrawerItem(index)} - /> - ))} - - - - {dynamicThemeSupported ? ( - - - Use Dynamic Theme - - - - - - ) : null} - - - Dark Theme - - - - - - - - - RTL - - - - - - - - - Collapsed drawer * - - - - - - - - - Custom font * - - - - - - - - - - {isIOS ? 'Highlight' : 'Ripple'} effect * - - - - - - - - {!collapsed && ( - - * - optional example toggles - - )} - - React Native Paper Version{' '} - {require('react-native-paper/package.json').version} - - - )} - - - Changing to RTL - - - Due to Expo Go limitations it is impossible to change RTL - dynamically. To do so, you need to create a development build of - Example app or change it statically by setting{' '} - forcesRTL property to true in{' '} - app.json within{' '} - example directory. - - - - - - - - - ); -} - -const styles = StyleSheet.create({ - drawerContent: { - flex: 1, - }, - preference: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingVertical: 12, - paddingHorizontal: 16, - }, - v3Preference: { - height: 56, - paddingHorizontal: 28, - }, - badge: { - alignSelf: 'center', - }, - collapsedSection: { - marginTop: 16, - }, - annotation: { - marginHorizontal: 24, - marginVertical: 6, - }, -}); - -export default DrawerItems; diff --git a/example/src/Examples/CardExample.tsx b/example/src/Examples/CardExample.tsx index ad3451e8b0..ab451ca30e 100644 --- a/example/src/Examples/CardExample.tsx +++ b/example/src/Examples/CardExample.tsx @@ -12,7 +12,7 @@ import { } from 'react-native-paper'; import { isWeb } from '../../utils'; -import { PreferencesContext } from '../PreferencesContext'; +import { usePreferences } from '../Preferences/usePreferences'; import ScreenWrapper from '../ScreenWrapper'; type Mode = 'elevated' | 'outlined' | 'contained'; @@ -21,7 +21,7 @@ const CardExample = () => { const { colors } = useTheme(); const [selectedMode, setSelectedMode] = React.useState('elevated' as Mode); const [isSelected, setIsSelected] = React.useState(false); - const preferences = React.useContext(PreferencesContext); + const { toggleTheme } = usePreferences(); const modes: Mode[] = ['elevated', 'outlined', 'contained']; @@ -179,13 +179,7 @@ const CardExample = () => { - { - preferences?.toggleTheme(); - }} - mode={selectedMode} - > + } diff --git a/example/src/Examples/SearchbarExample.tsx b/example/src/Examples/SearchbarExample.tsx index 739002156b..cb8c0cc931 100644 --- a/example/src/Examples/SearchbarExample.tsx +++ b/example/src/Examples/SearchbarExample.tsx @@ -11,10 +11,12 @@ import { useTheme, } from 'react-native-paper'; +import { usePreferences } from '../Preferences/usePreferences'; import ScreenWrapper from '../ScreenWrapper'; const SearchExample = () => { const navigation = useNavigation('Searchbar'); + const { togglePreferences } = usePreferences(); const [isVisible, setIsVisible] = React.useState(false); const [searchQueries, setSearchQuery] = React.useState({ @@ -27,7 +29,7 @@ const SearchExample = () => { searchWithoutBottomLine: '', loadingViewMode: '', clickableBack: '', - clickableDrawer: '', + clickablePreferences: '', clickableLoading: '', }); @@ -185,15 +187,15 @@ const SearchExample = () => { onChangeText={(query) => setSearchQuery({ ...searchQueries, - clickableDrawer: query, + clickablePreferences: query, }) } - value={searchQueries.clickableDrawer} + value={searchQueries.clickablePreferences} onIconPress={() => { Keyboard.dismiss(); - navigation.openDrawer(); + togglePreferences(); }} - icon="menu" + icon="cog" style={styles.searchbar} /> { - const preferences = React.useContext(PreferencesContext); + const { toggleTheme } = usePreferences(); const [options, setOptions] = React.useState({ showSnackbar: false, @@ -31,9 +31,7 @@ const SnackbarExample = () => { const action = { label: showLongerAction ? 'Toggle Theme' : 'Action', - onPress: () => { - preferences?.toggleTheme(); - }, + onPress: toggleTheme, }; return ( diff --git a/example/src/PreferencesContext.tsx b/example/src/Preferences/PreferencesContext.tsx similarity index 64% rename from example/src/PreferencesContext.tsx rename to example/src/Preferences/PreferencesContext.tsx index b5e381ae05..2e1c9b97e1 100644 --- a/example/src/PreferencesContext.tsx +++ b/example/src/Preferences/PreferencesContext.tsx @@ -2,17 +2,20 @@ import * as React from 'react'; import type { Theme } from 'react-native-paper'; -export const PreferencesContext = React.createContext<{ +export type Preferences = { toggleTheme: () => void; toggleRtl: () => void; - toggleCollapsed: () => void; toggleCustomFont: () => void; + togglePreferences: () => void; toggleRippleEffect: () => void; toggleShouldUseDynamicTheme?: () => void; + resetPreferences: () => void; theme: Theme; rtl: boolean; - collapsed: boolean; customFontLoaded: boolean; + preferencesVisible: boolean; rippleEffectEnabled: boolean; shouldUseDynamicTheme?: boolean; -} | null>(null); +}; + +export const PreferencesContext = React.createContext(null); diff --git a/example/src/Preferences/PreferencesModal.tsx b/example/src/Preferences/PreferencesModal.tsx new file mode 100644 index 0000000000..29592f9a30 --- /dev/null +++ b/example/src/Preferences/PreferencesModal.tsx @@ -0,0 +1,199 @@ +import { useState } from 'react'; +import { + Platform, + ScrollView, + StyleSheet, + useWindowDimensions, + View, +} from 'react-native'; + +import Constants, { ExecutionEnvironment } from 'expo-constants'; +import { + Button, + Dialog, + Portal, + Switch, + Text, + TouchableRipple, + useTheme, +} from 'react-native-paper'; +import { Modal } from 'react-native-paper'; + +import { usePreferences } from './usePreferences'; +import { dynamicThemeSupported, isWeb } from '../../utils'; + +export default function PreferencesModal() { + const [showRTLDialog, setShowRTLDialog] = useState(false); + const theme = useTheme(); + const { height: windowHeight } = useWindowDimensions(); + + const isIOS = Platform.OS === 'ios'; + const expoGoExecution = + Constants.executionEnvironment === ExecutionEnvironment.StoreClient; + + const { + toggleShouldUseDynamicTheme, + toggleTheme, + toggleRtl: toggleRTL, + toggleCustomFont, + toggleRippleEffect, + togglePreferences, + resetPreferences, + preferencesVisible, + customFontLoaded, + rippleEffectEnabled, + rtl: isRTL, + theme: { dark: isDarkTheme }, + shouldUseDynamicTheme, + } = usePreferences(); + + const _handleToggleRTL = () => { + if (!isWeb && expoGoExecution) { + setShowRTLDialog(true); + return; + } + + toggleRTL(); + }; + + const _handleDismissRTLDialog = () => { + setShowRTLDialog(false); + }; + + return ( + <> + + + + {dynamicThemeSupported ? ( + + + Use Dynamic Theme + + + + + + ) : null} + + + Dark Theme + + + + + + + + + RTL + + + + + + + + + Custom font * + + + + + + + + + + {isIOS ? 'Highlight' : 'Ripple'} effect * + + + + + + + + + + + * - optional example toggles + + + React Native Paper Version{' '} + {require('react-native-paper/package.json').version} + + + + + + + Changing to RTL + + + Due to Expo Go limitations it is impossible to change RTL + dynamically. To do so, you need to create a development build of + Example app or change it statically by setting{' '} + forcesRTL property to true in{' '} + app.json within{' '} + example directory. + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + modalContent: { + marginHorizontal: 24, + borderRadius: 28, + }, + scrollView: { + flexGrow: 0, + flexShrink: 1, + }, + scrollViewContent: { + paddingVertical: 12, + }, + preference: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 16, + }, + v3Preference: { + height: 56, + paddingHorizontal: 28, + }, + resetButton: { + marginHorizontal: 28, + marginTop: 12, + }, + annotation: { + marginHorizontal: 24, + marginVertical: 6, + }, +}); diff --git a/example/src/Preferences/setupPreferences.ts b/example/src/Preferences/setupPreferences.ts new file mode 100644 index 0000000000..01ce39220f --- /dev/null +++ b/example/src/Preferences/setupPreferences.ts @@ -0,0 +1,173 @@ +/* eslint-disable react-hooks/rules-of-hooks -- `setupPreferences` is a hook, named after its role at the root of the app */ +import * as React from 'react'; +import { I18nManager, Platform } from 'react-native'; + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Updates from 'expo-updates'; +import { + DarkTheme, + LightTheme, + DynamicLightTheme, + DynamicDarkTheme, +} from 'react-native-paper'; + +import type { Preferences } from './PreferencesContext'; +import { dynamicThemeSupported } from '../../utils'; + +const PERSISTENCE_KEY = 'NAVIGATION_STATE'; +const PREFERENCES_KEY = 'APP_PREFERENCES'; + +const getInitialRtl = () => { + if (Platform.OS === 'web' && typeof document !== 'undefined') { + return document.documentElement.dir === 'rtl'; + } + + return I18nManager.getConstants().isRTL; +}; + +export const navigationPersistor = { + async persist(state: unknown) { + await AsyncStorage.setItem(PERSISTENCE_KEY, JSON.stringify(state)); + }, + async restore() { + const state = await AsyncStorage.getItem(PERSISTENCE_KEY); + + return state ? JSON.parse(state) : undefined; + }, +}; + +export function setupPreferences() { + const [isReady, setIsReady] = React.useState(false); + + const [initialRtl] = React.useState(getInitialRtl); + + const [shouldUseDynamicTheme, setShouldUseDynamicTheme] = + React.useState(true); + const [isDarkMode, setIsDarkMode] = React.useState(false); + const [rtl, setRtl] = React.useState(initialRtl); + const [customFontLoaded, setCustomFont] = React.useState(false); + const [rippleEffectEnabled, setRippleEffectEnabled] = React.useState(true); + const [preferencesVisible, setPreferencesVisible] = React.useState(false); + + const [navigationKey, setNavigationKey] = React.useState(0); + + const theme = + dynamicThemeSupported && shouldUseDynamicTheme + ? isDarkMode + ? DynamicDarkTheme + : DynamicLightTheme + : isDarkMode + ? DarkTheme + : LightTheme; + + const direction: 'rtl' | 'ltr' = rtl ? 'rtl' : 'ltr'; + + React.useEffect(() => { + const restorePrefs = async () => { + try { + const prefString = await AsyncStorage.getItem(PREFERENCES_KEY); + const preferences = JSON.parse(prefString || ''); + + if (preferences) { + setIsDarkMode(preferences.theme === 'dark'); + + if (typeof preferences.rtl === 'boolean') { + setRtl(preferences.rtl); + } + } + } catch (e) { + // ignore error + } finally { + setIsReady(true); + } + }; + + void restorePrefs(); + }, []); + + React.useEffect(() => { + const savePrefs = async () => { + if (!isReady) { + return; + } + + try { + await AsyncStorage.setItem( + PREFERENCES_KEY, + JSON.stringify({ + theme: isDarkMode ? 'dark' : 'light', + rtl, + }) + ); + } catch (e) { + // ignore error + } + + if (Platform.OS === 'web' && typeof document !== 'undefined') { + document.documentElement.dir = direction; + } + + if (I18nManager.getConstants().isRTL !== rtl) { + I18nManager.forceRTL(rtl); + + if (Platform.OS !== 'web') { + await Updates.reloadAsync(); + } + } + }; + + void savePrefs(); + }, [direction, isDarkMode, isReady, rtl]); + + const resetPreferences = React.useCallback(async () => { + setShouldUseDynamicTheme(true); + setIsDarkMode(false); + setCustomFont(false); + setRippleEffectEnabled(true); + setPreferencesVisible(false); + + try { + await AsyncStorage.multiRemove([PREFERENCES_KEY, PERSISTENCE_KEY]); + } catch (e) { + // ignore error + } + + setNavigationKey((oldValue) => oldValue + 1); + }, []); + + const preferences: Preferences = React.useMemo( + () => ({ + toggleShouldUseDynamicTheme: () => + setShouldUseDynamicTheme((oldValue) => !oldValue), + toggleTheme: () => setIsDarkMode((oldValue) => !oldValue), + toggleRtl: () => setRtl((oldValue) => !oldValue), + toggleCustomFont: () => setCustomFont((oldValue) => !oldValue), + toggleRippleEffect: () => setRippleEffectEnabled((oldValue) => !oldValue), + togglePreferences: () => setPreferencesVisible((oldValue) => !oldValue), + resetPreferences, + preferencesVisible, + customFontLoaded, + rippleEffectEnabled, + shouldUseDynamicTheme, + theme, + rtl, + }), + [ + rtl, + theme, + customFontLoaded, + preferencesVisible, + shouldUseDynamicTheme, + rippleEffectEnabled, + resetPreferences, + ] + ); + + return { + preferences, + isReady, + isDarkMode, + direction, + navigationKey, + }; +} diff --git a/example/src/Preferences/usePreferences.tsx b/example/src/Preferences/usePreferences.tsx new file mode 100644 index 0000000000..85555f0223 --- /dev/null +++ b/example/src/Preferences/usePreferences.tsx @@ -0,0 +1,11 @@ +import { useContext } from 'react'; + +import { PreferencesContext } from './PreferencesContext'; + +export function usePreferences() { + const preferences = useContext(PreferencesContext); + + if (!preferences) throw new Error('PreferencesContext not provided'); + + return preferences; +} diff --git a/example/src/RootNavigator.tsx b/example/src/RootNavigator.tsx index b64fd1d802..c7bffa5e2b 100644 --- a/example/src/RootNavigator.tsx +++ b/example/src/RootNavigator.tsx @@ -1,6 +1,5 @@ import { Platform, StyleSheet, View } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; import { createNativeStackNavigator, createNativeStackScreen, @@ -9,6 +8,9 @@ import { import { Appbar } from 'react-native-paper'; import ExampleList, { examples } from './ExampleList'; +import PreferencesModal from './Preferences/PreferencesModal'; +import { usePreferences } from './Preferences/usePreferences'; +import MainSample from './Samples/MainSample'; const { TeamDetails, ...examplesWithoutParams } = examples; @@ -19,30 +21,47 @@ const fromEntries = ( ) => Object.fromEntries(entries) as Record; function Header({ navigation, route, options, back }: NativeStackHeaderProps) { - const drawerNavigation = useNavigation('Home'); + const { togglePreferences } = usePreferences(); + + const isIOS = Platform.OS === 'ios'; + + const backAction = navigation.goBack()} />; + const searchAction = ( + navigation.navigate('ExampleList')} + /> + ); return ( - {back ? ( - navigation.goBack()} /> - ) : ( - drawerNavigation.openDrawer()} - /> - )} + {back ? backAction : isIOS ? searchAction : null} + {!isIOS && searchAction} + ); } const Root = createNativeStackNavigator({ - layout: ({ children }) => {children}, + initialRouteName: 'MainSample', + layout: ({ children }) => ( + <> + {children} + + + ), screenOptions: { header: (props) =>
, }, screens: { + MainSample: createNativeStackScreen({ + screen: MainSample, + options: { + title: 'Sample', + }, + linking: '', + }), ExampleList: createNativeStackScreen({ screen: ExampleList, options: { diff --git a/example/src/Samples/MainSample.tsx b/example/src/Samples/MainSample.tsx new file mode 100644 index 0000000000..aeb97a1325 --- /dev/null +++ b/example/src/Samples/MainSample.tsx @@ -0,0 +1,3 @@ +export default function MainSample() { + return null; +} diff --git a/example/src/index.tsx b/example/src/index.tsx index afa3941044..8fd3ee839f 100644 --- a/example/src/index.tsx +++ b/example/src/index.tsx @@ -1,30 +1,18 @@ -import * as React from 'react'; -import { I18nManager, Platform } from 'react-native'; +import { Platform } from 'react-native'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { - createDrawerNavigator, - createDrawerScreen, -} from '@react-navigation/drawer'; import { createStaticNavigation } from '@react-navigation/native'; import { useFonts } from 'expo-font'; import { useKeepAwake } from 'expo-keep-awake'; import * as SplashScreen from 'expo-splash-screen'; import { StatusBar } from 'expo-status-bar'; -import * as Updates from 'expo-updates'; -import { - PaperProvider, - DarkTheme, - LightTheme, - DynamicLightTheme, - DynamicDarkTheme, -} from 'react-native-paper'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { PaperProvider } from 'react-native-paper'; -import DrawerItems from './DrawerItems'; -import { PreferencesContext } from './PreferencesContext'; +import { PreferencesContext } from './Preferences/PreferencesContext'; +import { + navigationPersistor, + setupPreferences, +} from './Preferences/setupPreferences'; import App from './RootNavigator'; -import { dynamicThemeSupported } from '../utils'; import { CombinedDarkTheme, CombinedDefaultTheme, @@ -32,54 +20,12 @@ import { createConfiguredFontTheme, } from '../utils/themes'; -const PERSISTENCE_KEY = 'NAVIGATION_STATE'; -const PREFERENCES_KEY = 'APP_PREFERENCES'; - -const getInitialRtl = () => { - if (Platform.OS === 'web' && typeof document !== 'undefined') { - return document.documentElement.dir === 'rtl'; - } - - return I18nManager.getConstants().isRTL; -}; +const Navigation = createStaticNavigation(App); -const Drawer = createDrawerNavigator({ - screens: { - Home: createDrawerScreen({ - screen: App, - options: { - headerShown: false, - }, - linking: { - path: '', - initialRouteName: 'ExampleList', - }, - }), - }, -}).with(({ Navigator }) => { - const preferences = React.useContext(PreferencesContext); - const insets = useSafeAreaInsets(); - const { left, right } = insets || { left: 0, right: 0 }; - const collapsedDrawerWidth = 100 + Math.max(left, right); - - return ( - } - /> - ); -}); - -const Navigation = createStaticNavigation(Drawer); - -type RootDrawerType = typeof Drawer; +type RootNavigationType = typeof App; declare module '@react-navigation/core' { - interface RootNavigator extends RootDrawerType {} + interface RootNavigator extends RootNavigationType {} } export default function PaperExample() { @@ -89,114 +35,15 @@ export default function PaperExample() { Abel: require('../assets/fonts/Abel-Regular.ttf'), }); - const [isReady, setIsReady] = React.useState(false); - - const [shouldUseDynamicTheme, setShouldUseDynamicTheme] = - React.useState(true); - const [isDarkMode, setIsDarkMode] = React.useState(false); - const [rtl, setRtl] = React.useState(getInitialRtl); - const [collapsed, setCollapsed] = React.useState(false); - const [customFontLoaded, setCustomFont] = React.useState(false); - const [rippleEffectEnabled, setRippleEffectEnabled] = React.useState(true); - - const theme = - dynamicThemeSupported && shouldUseDynamicTheme - ? isDarkMode - ? DynamicDarkTheme - : DynamicLightTheme - : isDarkMode - ? DarkTheme - : LightTheme; - - const direction = rtl ? 'rtl' : 'ltr'; - - React.useEffect(() => { - const restorePrefs = async () => { - try { - const prefString = await AsyncStorage.getItem(PREFERENCES_KEY); - const preferences = JSON.parse(prefString || ''); - - if (preferences) { - setIsDarkMode(preferences.theme === 'dark'); - - if (typeof preferences.rtl === 'boolean') { - setRtl(preferences.rtl); - } - } - } catch (e) { - // ignore error - } finally { - setIsReady(true); - } - }; - - void restorePrefs(); - }, []); - - React.useEffect(() => { - const savePrefs = async () => { - if (!isReady) { - return; - } - - try { - await AsyncStorage.setItem( - PREFERENCES_KEY, - JSON.stringify({ - theme: isDarkMode ? 'dark' : 'light', - rtl, - }) - ); - } catch (e) { - // ignore error - } - - if (Platform.OS === 'web' && typeof document !== 'undefined') { - document.documentElement.dir = direction; - } - - if (I18nManager.getConstants().isRTL !== rtl) { - I18nManager.forceRTL(rtl); - - if (Platform.OS !== 'web') { - await Updates.reloadAsync(); - } - } - }; - - void savePrefs(); - }, [direction, isDarkMode, isReady, rtl]); - - const preferences = React.useMemo( - () => ({ - toggleShouldUseDynamicTheme: () => - setShouldUseDynamicTheme((oldValue) => !oldValue), - toggleTheme: () => setIsDarkMode((oldValue) => !oldValue), - toggleRtl: () => setRtl((oldValue) => !oldValue), - toggleCollapsed: () => setCollapsed((oldValue) => !oldValue), - toggleCustomFont: () => setCustomFont((oldValue) => !oldValue), - toggleRippleEffect: () => setRippleEffectEnabled((oldValue) => !oldValue), - customFontLoaded, - rippleEffectEnabled, - shouldUseDynamicTheme, - theme, - collapsed, - rtl, - }), - [ - rtl, - theme, - collapsed, - customFontLoaded, - shouldUseDynamicTheme, - rippleEffectEnabled, - ] - ); + const { preferences, isReady, isDarkMode, direction, navigationKey } = + setupPreferences(); if (!isReady || !fontsLoaded) { return null; } + const { theme, customFontLoaded, rippleEffectEnabled } = preferences; + const combinedTheme = isDarkMode ? CombinedDarkTheme : CombinedDefaultTheme; const configuredFontTheme = createConfiguredFontTheme(combinedTheme); const configuredFontNavigationTheme = @@ -209,26 +56,15 @@ export default function PaperExample() { return ( { void SplashScreen.hideAsync(); }} From c070fcbf117fcb2c9098e5d60a2cfcc18a00fab3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Fedyna?= Date: Thu, 6 Aug 2026 15:26:43 +0200 Subject: [PATCH 2/5] feat: searchbar in the example list --- example/src/ExampleList.tsx | 97 ++++++++++++++------ example/src/ExampleListHeader.tsx | 44 +++++++++ example/src/Preferences/PreferencesModal.tsx | 2 +- example/src/RootNavigator.tsx | 5 +- 4 files changed, 117 insertions(+), 31 deletions(-) create mode 100644 example/src/ExampleListHeader.tsx diff --git a/example/src/ExampleList.tsx b/example/src/ExampleList.tsx index 53132e299f..7891374962 100644 --- a/example/src/ExampleList.tsx +++ b/example/src/ExampleList.tsx @@ -1,9 +1,11 @@ -import { FlatList } from 'react-native'; +import { useMemo, useState } from 'react'; +import { FlatList, StyleSheet, View } from 'react-native'; import { useNavigation } from '@react-navigation/native'; -import { Divider, List, useTheme } from 'react-native-paper'; +import { Divider, List, Text, useTheme } from 'react-native-paper'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import ExampleListHeader from './ExampleListHeader'; import ActivityIndicatorExample from './Examples/ActivityIndicatorExample'; import AppbarExample from './Examples/AppbarExample'; import AvatarExample from './Examples/AvatarExample'; @@ -116,32 +118,71 @@ export default function ExampleList() { const { colors } = useTheme(); const safeArea = useSafeAreaInsets(); + const [query, setQuery] = useState(''); + + const filteredData = useMemo(() => { + const search = query.trim().toLowerCase(); + + if (!search) { + return data; + } + + return data.filter( + ({ id, data: example }) => + example.title.toLowerCase().includes(search) || + id.toLowerCase().includes(search) + ); + }, [query]); + return ( - ( - { - // @ts-expect-error TypeScript can't call overloaded functions with union arguments. - // https://github.com/microsoft/TypeScript/issues/40803 - navigation.navigate(item.id); - }} - /> - )} - keyExtractor={({ id }) => id} - data={data} - /> + + + ( + { + // @ts-expect-error TypeScript can't call overloaded functions with union arguments. + // https://github.com/microsoft/TypeScript/issues/40803 + navigation.navigate(item.id); + }} + /> + )} + ListEmptyComponent={ + + {`No examples match "${query.trim()}"`} + + } + keyExtractor={({ id }) => id} + data={filteredData} + /> + ); } + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + empty: { + padding: 16, + textAlign: 'center', + }, +}); diff --git a/example/src/ExampleListHeader.tsx b/example/src/ExampleListHeader.tsx new file mode 100644 index 0000000000..d84c207eb1 --- /dev/null +++ b/example/src/ExampleListHeader.tsx @@ -0,0 +1,44 @@ +import { StyleSheet } from 'react-native'; + +import { useNavigation } from '@react-navigation/native'; +import { Appbar, Searchbar } from 'react-native-paper'; + +import { usePreferences } from './Preferences/usePreferences'; + +type Props = { + query: string; + onQueryChange: (query: string) => void; +}; + +export default function ExampleListHeader({ query, onQueryChange }: Props) { + const navigation = useNavigation('ExampleList'); + const { togglePreferences } = usePreferences(); + + const canGoBack = navigation.canGoBack(); + + return ( + + navigation.goBack() : undefined} + searchAccessibilityLabel={canGoBack ? 'go back' : 'search'} + traileringIcon="cog" + traileringIconAccessibilityLabel="preferences" + onTraileringIconPress={togglePreferences} + clearAccessibilityLabel="clear search" + autoCorrect={false} + autoCapitalize="none" + style={styles.searchbar} + /> + + ); +} + +const styles = StyleSheet.create({ + searchbar: { + flex: 1, + }, +}); diff --git a/example/src/Preferences/PreferencesModal.tsx b/example/src/Preferences/PreferencesModal.tsx index 29592f9a30..fa2eb731a8 100644 --- a/example/src/Preferences/PreferencesModal.tsx +++ b/example/src/Preferences/PreferencesModal.tsx @@ -190,7 +190,7 @@ const styles = StyleSheet.create({ }, resetButton: { marginHorizontal: 28, - marginTop: 12, + marginVertical: 12, }, annotation: { marginHorizontal: 24, diff --git a/example/src/RootNavigator.tsx b/example/src/RootNavigator.tsx index c7bffa5e2b..6819a458e6 100644 --- a/example/src/RootNavigator.tsx +++ b/example/src/RootNavigator.tsx @@ -34,10 +34,10 @@ function Header({ navigation, route, options, back }: NativeStackHeaderProps) { ); return ( - + {back ? backAction : isIOS ? searchAction : null} - {!isIOS && searchAction} + {!isIOS && !back && searchAction} ); @@ -66,6 +66,7 @@ const Root = createNativeStackNavigator({ screen: ExampleList, options: { title: 'Examples', + headerShown: false, }, linking: '', }), From 9575f6a77b8df7e0e3fd01a00acebd05ba3b5c8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Fedyna?= Date: Wed, 12 Aug 2026 11:28:36 +0200 Subject: [PATCH 3/5] feat: simpler app --- example/src/RootNavigator.tsx | 27 +++- example/src/Samples/ArticleSample.tsx | 115 +++++++++++++++++ example/src/Samples/ContactsSample.tsx | 108 ++++++++++++++++ example/src/Samples/HelpCenterSample.tsx | 111 ++++++++++++++++ example/src/Samples/MainSample.tsx | 3 - example/src/Samples/OrdersSample.tsx | 157 +++++++++++++++++++++++ example/src/Samples/PlayerSample.tsx | 107 +++++++++++++++ example/src/Samples/SettingsSample.tsx | 104 +++++++++++++++ example/src/Samples/SignUpSample.tsx | 108 ++++++++++++++++ example/src/Samples/WorkspaceSample.tsx | 140 ++++++++++++++++++++ example/src/Samples/types.ts | 47 +++++++ example/src/SamplesList.tsx | 82 ++++++++++++ 12 files changed, 1101 insertions(+), 8 deletions(-) create mode 100644 example/src/Samples/ArticleSample.tsx create mode 100644 example/src/Samples/ContactsSample.tsx create mode 100644 example/src/Samples/HelpCenterSample.tsx delete mode 100644 example/src/Samples/MainSample.tsx create mode 100644 example/src/Samples/OrdersSample.tsx create mode 100644 example/src/Samples/PlayerSample.tsx create mode 100644 example/src/Samples/SettingsSample.tsx create mode 100644 example/src/Samples/SignUpSample.tsx create mode 100644 example/src/Samples/WorkspaceSample.tsx create mode 100644 example/src/Samples/types.ts create mode 100644 example/src/SamplesList.tsx diff --git a/example/src/RootNavigator.tsx b/example/src/RootNavigator.tsx index 6819a458e6..dcb07e3168 100644 --- a/example/src/RootNavigator.tsx +++ b/example/src/RootNavigator.tsx @@ -10,11 +10,12 @@ import { Appbar } from 'react-native-paper'; import ExampleList, { examples } from './ExampleList'; import PreferencesModal from './Preferences/PreferencesModal'; import { usePreferences } from './Preferences/usePreferences'; -import MainSample from './Samples/MainSample'; +import SamplesList, { samples } from './SamplesList'; const { TeamDetails, ...examplesWithoutParams } = examples; type ExampleRouteName = keyof typeof examplesWithoutParams; +type SampleRouteName = keyof typeof samples; const fromEntries = ( entries: Array<[Key, Value]> @@ -44,7 +45,7 @@ function Header({ navigation, route, options, back }: NativeStackHeaderProps) { } const Root = createNativeStackNavigator({ - initialRouteName: 'MainSample', + initialRouteName: 'SamplesList', layout: ({ children }) => ( <> {children} @@ -55,13 +56,29 @@ const Root = createNativeStackNavigator({ header: (props) =>
, }, screens: { - MainSample: createNativeStackScreen({ - screen: MainSample, + SamplesList: createNativeStackScreen({ + screen: SamplesList, options: { - title: 'Sample', + title: 'Samples', }, linking: '', }), + ...fromEntries( + ( + Object.entries(samples) as [ + SampleRouteName, + (typeof samples)[SampleRouteName], + ][] + ).map(([id, sample]) => [ + id, + createNativeStackScreen({ + screen: sample.screen, + options: { + title: sample.title, + }, + }), + ]) + ), ExampleList: createNativeStackScreen({ screen: ExampleList, options: { diff --git a/example/src/Samples/ArticleSample.tsx b/example/src/Samples/ArticleSample.tsx new file mode 100644 index 0000000000..888edd0342 --- /dev/null +++ b/example/src/Samples/ArticleSample.tsx @@ -0,0 +1,115 @@ +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { + Button, + Card, + Chip, + FAB, + IconButton, + Text, + Tooltip, +} from 'react-native-paper'; + +import type { SampleConfig } from './types'; +import ScreenWrapper from '../ScreenWrapper'; + +export const ArticleSampleConfig: SampleConfig = { + title: 'Article', + icon: 'newspaper-variant-outline', + components: [ + 'Button', + 'Card', + 'Chip', + 'FAB', + 'IconButton', + 'Text', + 'Tooltip', + ], +}; + +const TOPICS = ['Material 3', 'Design systems', 'React Native']; + +const ArticleSample = () => { + const [liked, setLiked] = React.useState(false); + const [bookmarked, setBookmarked] = React.useState(false); + const [menuExpanded, setMenuExpanded] = React.useState(false); + + return ( + <> + + + + + + + Material Design 3 leans on tonal color, larger corner radii and + motion to express hierarchy. Paper ships those decisions as + components, so a screen built from the defaults already follows + the spec. + + + {TOPICS.map((topic) => ( + + {topic} + + ))} + + + + + setLiked(!liked)} + /> + + + setBookmarked(!bookmarked)} + /> + + + + + + + setMenuExpanded(false)} + trigger={{ + icon: 'share-variant', + onPress: () => setMenuExpanded(true), + }} + items={[ + { icon: 'link', label: 'Copy link', onPress: () => {} }, + { icon: 'email', label: 'Send by email', onPress: () => {} }, + { icon: 'comment-outline', label: 'Comment', onPress: () => {} }, + ]} + /> + + ); +}; + +const styles = StyleSheet.create({ + content: { + padding: 16, + }, + cardContent: { + gap: 16, + }, + topics: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, +}); + +export default ArticleSample; diff --git a/example/src/Samples/ContactsSample.tsx b/example/src/Samples/ContactsSample.tsx new file mode 100644 index 0000000000..641fef07ab --- /dev/null +++ b/example/src/Samples/ContactsSample.tsx @@ -0,0 +1,108 @@ +import * as React from 'react'; +import { FlatList, StyleSheet } from 'react-native'; + +import { + Avatar, + Badge, + Divider, + FAB, + List, + Searchbar, +} from 'react-native-paper'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import type { SampleConfig } from './types'; +import ScreenWrapper from '../ScreenWrapper'; + +export const ContactsSampleConfig: SampleConfig = { + title: 'Contacts', + icon: 'account-group-outline', + components: ['Avatar', 'Badge', 'Divider', 'FAB', 'List', 'Searchbar'], +}; + +const CONTACTS = [ + { id: '1', name: 'Ada Lovelace', role: 'Engineering', unread: 3 }, + { id: '2', name: 'Grace Hopper', role: 'Engineering', unread: 0 }, + { id: '3', name: 'Katherine Johnson', role: 'Research', unread: 12 }, + { id: '4', name: 'Mary Jackson', role: 'Research', unread: 0 }, + { id: '5', name: 'Radia Perlman', role: 'Networking', unread: 1 }, + { id: '6', name: 'Barbara Liskov', role: 'Architecture', unread: 0 }, +]; + +const getInitials = (name: string) => + name + .split(' ') + .map((part) => part[0]) + .join(''); + +const ContactsSample = () => { + const insets = useSafeAreaInsets(); + const [query, setQuery] = React.useState(''); + + const search = query.trim().toLowerCase(); + const contacts = search + ? CONTACTS.filter((contact) => contact.name.toLowerCase().includes(search)) + : CONTACTS; + + return ( + <> + + + contact.id} + keyboardShouldPersistTaps="handled" + ItemSeparatorComponent={Divider} + contentContainerStyle={{ paddingBottom: insets.bottom + 96 }} + renderItem={({ item }) => ( + {}} + left={({ style }) => ( + + )} + right={({ style }) => + item.unread ? ( + {item.unread} + ) : null + } + /> + )} + /> + + + {}} + style={[styles.fab, { bottom: insets.bottom + 16 }]} + /> + + ); +}; + +const styles = StyleSheet.create({ + searchbar: { + margin: 16, + }, + badge: { + alignSelf: 'center', + }, + fab: { + position: 'absolute', + right: 16, + }, +}); + +export default ContactsSample; diff --git a/example/src/Samples/HelpCenterSample.tsx b/example/src/Samples/HelpCenterSample.tsx new file mode 100644 index 0000000000..17293d2366 --- /dev/null +++ b/example/src/Samples/HelpCenterSample.tsx @@ -0,0 +1,111 @@ +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { + Icon, + List, + Modal, + Portal, + Text, + TouchableRipple, + useTheme, +} from 'react-native-paper'; + +import type { SampleConfig } from './types'; +import ScreenWrapper from '../ScreenWrapper'; + +export const HelpCenterSampleConfig: SampleConfig = { + title: 'Help center', + icon: 'help-circle-outline', + components: ['Icon', 'List', 'Modal', 'Portal', 'Text', 'TouchableRipple'], +}; + +const TOPICS = [ + { + id: 'getting-started', + title: 'Getting started', + icon: 'rocket-launch-outline', + answers: ['Install the app', 'Create your first workspace'], + }, + { + id: 'billing', + title: 'Billing', + icon: 'credit-card-outline', + answers: ['Update your payment method', 'Download past invoices'], + }, + { + id: 'privacy', + title: 'Privacy', + icon: 'shield-lock-outline', + answers: ['Manage data sharing', 'Delete your account'], + }, +]; + +const HelpCenterSample = () => { + const { colors } = useTheme(); + const [contactVisible, setContactVisible] = React.useState(false); + + return ( + <> + + + + {TOPICS.map((topic) => ( + } + > + {topic.answers.map((answer) => ( + {}} /> + ))} + + ))} + + + + setContactVisible(true)}> + + + Still stuck? Contact support + + + + + + setContactVisible(false)} + contentContainerStyle={[ + styles.modal, + { backgroundColor: colors.surface }, + ]} + > + Contact support + + Write to support@example.com and we will get back to you within one + business day. + + + + + ); +}; + +const styles = StyleSheet.create({ + contact: { + flexDirection: 'row', + alignItems: 'center', + gap: 16, + padding: 16, + }, + modal: { + margin: 24, + padding: 24, + borderRadius: 28, + gap: 8, + }, +}); + +export default HelpCenterSample; diff --git a/example/src/Samples/MainSample.tsx b/example/src/Samples/MainSample.tsx deleted file mode 100644 index aeb97a1325..0000000000 --- a/example/src/Samples/MainSample.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function MainSample() { - return null; -} diff --git a/example/src/Samples/OrdersSample.tsx b/example/src/Samples/OrdersSample.tsx new file mode 100644 index 0000000000..5212715192 --- /dev/null +++ b/example/src/Samples/OrdersSample.tsx @@ -0,0 +1,157 @@ +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { + Banner, + Button, + Checkbox, + DataTable, + Menu, + Text, +} from 'react-native-paper'; + +import type { SampleConfig } from './types'; +import ScreenWrapper from '../ScreenWrapper'; + +export const OrdersSampleConfig: SampleConfig = { + title: 'Orders', + icon: 'table-large', + components: ['Banner', 'Button', 'Checkbox', 'DataTable', 'Menu', 'Text'], +}; + +const ORDERS = [ + { id: 'A-1041', customer: 'Ada Lovelace', total: 128 }, + { id: 'A-1042', customer: 'Grace Hopper', total: 64 }, + { id: 'A-1043', customer: 'Alan Turing', total: 512 }, + { id: 'A-1044', customer: 'Barbara Liskov', total: 96 }, + { id: 'A-1045', customer: 'Radia Perlman', total: 240 }, + { id: 'A-1046', customer: 'Mary Jackson', total: 32 }, +]; + +const ITEMS_PER_PAGE = 3; + +type SortKey = 'id' | 'customer' | 'total'; + +const SORT_LABELS: Record = { + id: 'Order number', + customer: 'Customer', + total: 'Total', +}; + +const OrdersSample = () => { + const [bannerVisible, setBannerVisible] = React.useState(true); + const [menuVisible, setMenuVisible] = React.useState(false); + const [sortBy, setSortBy] = React.useState('id'); + const [selected, setSelected] = React.useState([]); + const [page, setPage] = React.useState(0); + + const sorted = ORDERS.slice().sort((a, b) => + sortBy === 'total' + ? a.total - b.total + : String(a[sortBy]).localeCompare(String(b[sortBy])) + ); + + const from = page * ITEMS_PER_PAGE; + const to = Math.min(from + ITEMS_PER_PAGE, sorted.length); + + const toggleSelected = (id: string) => + setSelected((current) => + current.includes(id) + ? current.filter((item) => item !== id) + : [...current, id] + ); + + const selectSort = (key: SortKey) => { + setSortBy(key); + setMenuVisible(false); + setPage(0); + }; + + return ( + + setBannerVisible(false) }]} + > + Two orders are waiting for a payment confirmation. + + + + {selected.length} selected + setMenuVisible(false)} + anchor={ + + } + > + {(Object.keys(SORT_LABELS) as SortKey[]).map((key) => ( + selectSort(key)} + /> + ))} + + + + + + + Order + + Customer + + Total + + + {sorted.slice(from, to).map((order) => ( + + + toggleSelected(order.id)} + /> + + {order.id} + + {order.customer} + + {`$${order.total}`} + + ))} + + + + + ); +}; + +const styles = StyleSheet.create({ + toolbar: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 16, + }, + selectColumn: { + flex: 0.5, + }, + customerColumn: { + flex: 2, + }, +}); + +export default OrdersSample; diff --git a/example/src/Samples/PlayerSample.tsx b/example/src/Samples/PlayerSample.tsx new file mode 100644 index 0000000000..c95a06463c --- /dev/null +++ b/example/src/Samples/PlayerSample.tsx @@ -0,0 +1,107 @@ +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { + ActivityIndicator, + Icon, + ProgressBar, + SegmentedButtons, + Surface, + Text, + ToggleButton, +} from 'react-native-paper'; + +import type { SampleConfig } from './types'; +import ScreenWrapper from '../ScreenWrapper'; + +export const PlayerSampleConfig: SampleConfig = { + title: 'Now playing', + icon: 'play-circle-outline', + components: [ + 'ActivityIndicator', + 'Icon', + 'ProgressBar', + 'SegmentedButtons', + 'Surface', + 'Text', + 'ToggleButton', + ], +}; + +const PlayerSample = () => { + const [repeat, setRepeat] = React.useState('off'); + const [speed, setSpeed] = React.useState('1'); + const [buffering, setBuffering] = React.useState(false); + + React.useEffect(() => { + if (!buffering) { + return; + } + + const timeout = setTimeout(() => setBuffering(false), 1500); + + return () => clearTimeout(timeout); + }, [buffering]); + + return ( + + + + + + + Nightfall + Aurora Skies · Long Way Home + + + + + + + + + + + { + setSpeed(value); + setBuffering(true); + }} + buttons={[ + { value: '0.5', label: '0.5x' }, + { value: '1', label: '1x' }, + { value: '1.5', label: '1.5x' }, + { value: '2', label: '2x' }, + ]} + /> + + + + + {buffering ? 'Buffering at new speed…' : 'Ready'} + + + + ); +}; + +const styles = StyleSheet.create({ + content: { + padding: 16, + gap: 24, + }, + cover: { + height: 200, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 16, + }, + status: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, +}); + +export default PlayerSample; diff --git a/example/src/Samples/SettingsSample.tsx b/example/src/Samples/SettingsSample.tsx new file mode 100644 index 0000000000..ea83866c99 --- /dev/null +++ b/example/src/Samples/SettingsSample.tsx @@ -0,0 +1,104 @@ +import * as React from 'react'; +import { StyleSheet } from 'react-native'; + +import { + Button, + Dialog, + Divider, + List, + Portal, + RadioButton, + Switch, + Text, +} from 'react-native-paper'; + +import type { SampleConfig } from './types'; +import ScreenWrapper from '../ScreenWrapper'; + +export const SettingsSampleConfig: SampleConfig = { + title: 'Settings', + icon: 'cog-outline', + components: [ + 'Button', + 'Dialog', + 'Divider', + 'List', + 'Portal', + 'RadioButton', + 'Switch', + 'Text', + ], +}; + +const SettingsSample = () => { + const [notifications, setNotifications] = React.useState(true); + const [backgroundSync, setBackgroundSync] = React.useState(false); + const [density, setDensity] = React.useState('comfortable'); + const [dialogVisible, setDialogVisible] = React.useState(false); + + const hideDialog = () => setDialogVisible(false); + + return ( + + + ( + + )} + /> + ( + + )} + /> + + + + + + + + + + + + + + + } + onPress={() => setDialogVisible(true)} + /> + + + + + + Sign out? + + + You will need to sign in again to access your workspace. + + + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + dialogTitle: { + textAlign: 'center', + }, +}); + +export default SettingsSample; diff --git a/example/src/Samples/SignUpSample.tsx b/example/src/Samples/SignUpSample.tsx new file mode 100644 index 0000000000..80ce6ac042 --- /dev/null +++ b/example/src/Samples/SignUpSample.tsx @@ -0,0 +1,108 @@ +import * as React from 'react'; +import { StyleSheet } from 'react-native'; + +import { + Button, + Checkbox, + Divider, + Snackbar, + Text, + TextInput, +} from 'react-native-paper'; +import type { TextInputAccessoryProps } from 'react-native-paper'; + +import type { SampleConfig } from './types'; +import ScreenWrapper from '../ScreenWrapper'; + +export const SignUpSampleConfig: SampleConfig = { + title: 'Sign up', + icon: 'account-plus-outline', + components: [ + 'Button', + 'Checkbox', + 'Divider', + 'Snackbar', + 'Text', + 'TextInput', + ], +}; + +const SignUpSample = () => { + const [email, setEmail] = React.useState(''); + const [password, setPassword] = React.useState(''); + const [secure, setSecure] = React.useState(true); + const [accepted, setAccepted] = React.useState(false); + const [submitted, setSubmitted] = React.useState(false); + + const emailIcon = (props: TextInputAccessoryProps) => ( + + ); + + const passwordIcon = (props: TextInputAccessoryProps) => ( + setSecure(!secure)} + /> + ); + + return ( + <> + + Create your account + + Sign up to sync your projects across every device. + + + + + + setAccepted(!accepted)} + /> + + + + + + + + setSubmitted(false)}> + Account created + + + ); +}; + +const styles = StyleSheet.create({ + content: { + padding: 16, + gap: 16, + }, +}); + +export default SignUpSample; diff --git a/example/src/Samples/WorkspaceSample.tsx b/example/src/Samples/WorkspaceSample.tsx new file mode 100644 index 0000000000..199e252bde --- /dev/null +++ b/example/src/Samples/WorkspaceSample.tsx @@ -0,0 +1,140 @@ +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { useNavigation } from '@react-navigation/native'; +import { + Appbar, + Avatar, + BottomNavigation, + Drawer, + Text, +} from 'react-native-paper'; +import type { BottomNavigationRoute } from 'react-native-paper'; + +import type { SampleConfig } from './types'; +import ScreenWrapper from '../ScreenWrapper'; + +export const WorkspaceSampleConfig: SampleConfig = { + title: 'Workspace', + icon: 'view-dashboard-outline', + components: ['Appbar', 'Avatar', 'BottomNavigation', 'Drawer', 'Text'], +}; + +const FOLDERS = [ + { key: 'primary', label: 'Primary', icon: 'inbox' }, + { key: 'starred', label: 'Starred', icon: 'star-outline' }, + { key: 'archive', label: 'Archive', icon: 'archive-outline' }, +]; + +const TEAM = ['Ada Lovelace', 'Grace Hopper', 'Alan Turing']; + +const InboxRoute = () => { + const [folder, setFolder] = React.useState('primary'); + + return ( + + + {FOLDERS.map((item) => ( + setFolder(item.key)} + /> + ))} + + + ); +}; + +const TasksRoute = () => ( + + Today + Review the release checklist + Prepare the design handoff + +); + +const TeamRoute = () => ( + + {TEAM.map((member) => ( + + + {member} + + ))} + +); + +const renderScene = BottomNavigation.SceneMap({ + inbox: InboxRoute, + tasks: TasksRoute, + team: TeamRoute, +}); + +const routes: BottomNavigationRoute[] = [ + { + key: 'inbox', + title: 'Inbox', + focusedIcon: 'inbox', + unfocusedIcon: 'inbox-outline', + badge: 4, + }, + { + key: 'tasks', + title: 'Tasks', + focusedIcon: 'check-circle', + unfocusedIcon: 'check-circle-outline', + }, + { + key: 'team', + title: 'Team', + focusedIcon: 'account-group', + unfocusedIcon: 'account-group-outline', + }, +]; + +const WorkspaceSample = () => { + const navigation = useNavigation('WorkspaceSample'); + const [index, setIndex] = React.useState(0); + + React.useLayoutEffect(() => { + navigation.setOptions({ headerShown: false }); + }, [navigation]); + + return ( + + + navigation.goBack()} /> + + {}} /> + + + + ); +}; + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + content: { + padding: 16, + gap: 16, + }, + member: { + flexDirection: 'row', + alignItems: 'center', + gap: 16, + }, +}); + +export default WorkspaceSample; diff --git a/example/src/Samples/types.ts b/example/src/Samples/types.ts new file mode 100644 index 0000000000..96e4b179a9 --- /dev/null +++ b/example/src/Samples/types.ts @@ -0,0 +1,47 @@ +/** + * Every component exported by `react-native-paper`, so a sample can only be + * tagged with a name that actually exists. + */ +export type PaperComponentName = + | 'ActivityIndicator' + | 'Appbar' + | 'Avatar' + | 'Badge' + | 'Banner' + | 'BottomNavigation' + | 'Button' + | 'Card' + | 'Checkbox' + | 'Chip' + | 'DataTable' + | 'Dialog' + | 'Divider' + | 'Drawer' + | 'FAB' + | 'Icon' + | 'IconButton' + | 'List' + | 'Menu' + | 'Modal' + | 'Portal' + | 'ProgressBar' + | 'RadioButton' + | 'Searchbar' + | 'SegmentedButtons' + | 'Snackbar' + | 'Surface' + | 'Switch' + | 'Text' + | 'TextInput' + | 'ToggleButton' + | 'Tooltip' + | 'TouchableRipple'; + +export type SampleConfig = { + title: string; + icon: string; + /** + * Components the sample is built from, in alphabetical order. + */ + components: PaperComponentName[]; +}; diff --git a/example/src/SamplesList.tsx b/example/src/SamplesList.tsx new file mode 100644 index 0000000000..722cb2a30b --- /dev/null +++ b/example/src/SamplesList.tsx @@ -0,0 +1,82 @@ +import { FlatList, StyleSheet, View } from 'react-native'; + +import { useNavigation } from '@react-navigation/native'; +import { Avatar, Card, Chip } from 'react-native-paper'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import ArticleSample, { ArticleSampleConfig } from './Samples/ArticleSample'; +import ContactsSample, { ContactsSampleConfig } from './Samples/ContactsSample'; +import HelpCenterSample, { + HelpCenterSampleConfig, +} from './Samples/HelpCenterSample'; +import OrdersSample, { OrdersSampleConfig } from './Samples/OrdersSample'; +import PlayerSample, { PlayerSampleConfig } from './Samples/PlayerSample'; +import SettingsSample, { SettingsSampleConfig } from './Samples/SettingsSample'; +import SignUpSample, { SignUpSampleConfig } from './Samples/SignUpSample'; +import WorkspaceSample, { + WorkspaceSampleConfig, +} from './Samples/WorkspaceSample'; + +export const samples = { + SignUpSample: { ...SignUpSampleConfig, screen: SignUpSample }, + ContactsSample: { ...ContactsSampleConfig, screen: ContactsSample }, + ArticleSample: { ...ArticleSampleConfig, screen: ArticleSample }, + SettingsSample: { ...SettingsSampleConfig, screen: SettingsSample }, + PlayerSample: { ...PlayerSampleConfig, screen: PlayerSample }, + OrdersSample: { ...OrdersSampleConfig, screen: OrdersSample }, + HelpCenterSample: { ...HelpCenterSampleConfig, screen: HelpCenterSample }, + WorkspaceSample: { ...WorkspaceSampleConfig, screen: WorkspaceSample }, +}; + +type SampleId = keyof typeof samples; + +const data = (Object.keys(samples) as SampleId[]).map((id) => ({ + id, + ...samples[id], +})); + +export default function SamplesList() { + const navigation = useNavigation('SamplesList'); + const safeArea = useSafeAreaInsets(); + + return ( + id} + contentContainerStyle={[ + styles.content, + { paddingBottom: safeArea.bottom + 16 }, + ]} + showsVerticalScrollIndicator={false} + renderItem={({ item }) => ( + navigation.navigate(item.id)}> + } + /> + + + {item.components.map((component) => ( + + {component} + + ))} + + + + )} + /> + ); +} + +const styles = StyleSheet.create({ + content: { + padding: 16, + gap: 16, + }, + tags: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, +}); From 6aa3587480d22a146e06d2f72f46e4bdf323aca5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Fedyna?= Date: Thu, 13 Aug 2026 10:42:27 +0200 Subject: [PATCH 4/5] feat: self review --- example/src/ExampleListHeader.tsx | 4 ++- example/src/RootNavigator.tsx | 9 +++++-- example/src/Samples/ArticleSample.tsx | 34 +++++++++++++++----------- example/src/Samples/PlayerSample.tsx | 35 ++++----------------------- example/src/Samples/SignUpSample.tsx | 3 ++- example/src/index.tsx | 1 + 6 files changed, 38 insertions(+), 48 deletions(-) diff --git a/example/src/ExampleListHeader.tsx b/example/src/ExampleListHeader.tsx index d84c207eb1..e08deef858 100644 --- a/example/src/ExampleListHeader.tsx +++ b/example/src/ExampleListHeader.tsx @@ -22,7 +22,9 @@ export default function ExampleListHeader({ query, onQueryChange }: Props) { placeholder="Search examples" value={query} onChangeText={onQueryChange} - icon={canGoBack ? 'arrow-left' : 'magnify'} + icon={ + canGoBack ? { source: 'arrow-left', direction: 'auto' } : 'magnify' + } onIconPress={canGoBack ? () => navigation.goBack() : undefined} searchAccessibilityLabel={canGoBack ? 'go back' : 'search'} traileringIcon="cog" diff --git a/example/src/RootNavigator.tsx b/example/src/RootNavigator.tsx index dcb07e3168..cba65a5a3f 100644 --- a/example/src/RootNavigator.tsx +++ b/example/src/RootNavigator.tsx @@ -30,6 +30,7 @@ function Header({ navigation, route, options, back }: NativeStackHeaderProps) { const searchAction = ( navigation.navigate('ExampleList')} /> ); @@ -39,7 +40,11 @@ function Header({ navigation, route, options, back }: NativeStackHeaderProps) { {back ? backAction : isIOS ? searchAction : null} {!isIOS && !back && searchAction} - + ); } @@ -85,7 +90,7 @@ const Root = createNativeStackNavigator({ title: 'Examples', headerShown: false, }, - linking: '', + linking: 'examples', }), ...fromEntries( ( diff --git a/example/src/Samples/ArticleSample.tsx b/example/src/Samples/ArticleSample.tsx index 888edd0342..8f70ed69af 100644 --- a/example/src/Samples/ArticleSample.tsx +++ b/example/src/Samples/ArticleSample.tsx @@ -60,20 +60,22 @@ const ArticleSample = () => { - - setLiked(!liked)} - /> - - - setBookmarked(!bookmarked)} - /> - + + + setLiked(!liked)} + /> + + + setBookmarked(!bookmarked)} + /> + + @@ -105,6 +107,10 @@ const styles = StyleSheet.create({ cardContent: { gap: 16, }, + cardIcons: { + flexDirection: 'row', + alignItems: 'center', + }, topics: { flexDirection: 'row', flexWrap: 'wrap', diff --git a/example/src/Samples/PlayerSample.tsx b/example/src/Samples/PlayerSample.tsx index c95a06463c..d8f72c2638 100644 --- a/example/src/Samples/PlayerSample.tsx +++ b/example/src/Samples/PlayerSample.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import { StyleSheet, View } from 'react-native'; import { - ActivityIndicator, Icon, ProgressBar, SegmentedButtons, @@ -18,7 +17,6 @@ export const PlayerSampleConfig: SampleConfig = { title: 'Now playing', icon: 'play-circle-outline', components: [ - 'ActivityIndicator', 'Icon', 'ProgressBar', 'SegmentedButtons', @@ -31,17 +29,6 @@ export const PlayerSampleConfig: SampleConfig = { const PlayerSample = () => { const [repeat, setRepeat] = React.useState('off'); const [speed, setSpeed] = React.useState('1'); - const [buffering, setBuffering] = React.useState(false); - - React.useEffect(() => { - if (!buffering) { - return; - } - - const timeout = setTimeout(() => setBuffering(false), 1500); - - return () => clearTimeout(timeout); - }, [buffering]); return ( @@ -56,7 +43,10 @@ const PlayerSample = () => { - + value && setRepeat(value)} + > @@ -64,10 +54,7 @@ const PlayerSample = () => { { - setSpeed(value); - setBuffering(true); - }} + onValueChange={setSpeed} buttons={[ { value: '0.5', label: '0.5x' }, { value: '1', label: '1x' }, @@ -75,13 +62,6 @@ const PlayerSample = () => { { value: '2', label: '2x' }, ]} /> - - - - - {buffering ? 'Buffering at new speed…' : 'Ready'} - - ); }; @@ -97,11 +77,6 @@ const styles = StyleSheet.create({ justifyContent: 'center', borderRadius: 16, }, - status: { - flexDirection: 'row', - alignItems: 'center', - gap: 12, - }, }); export default PlayerSample; diff --git a/example/src/Samples/SignUpSample.tsx b/example/src/Samples/SignUpSample.tsx index 80ce6ac042..ee85c84a61 100644 --- a/example/src/Samples/SignUpSample.tsx +++ b/example/src/Samples/SignUpSample.tsx @@ -41,7 +41,8 @@ const SignUpSample = () => { const passwordIcon = (props: TextInputAccessoryProps) => ( setSecure(!secure)} /> ); diff --git a/example/src/index.tsx b/example/src/index.tsx index 8fd3ee839f..c376daad44 100644 --- a/example/src/index.tsx +++ b/example/src/index.tsx @@ -65,6 +65,7 @@ export default function PaperExample() { theme={navigationTheme} direction={direction} persistor={navigationPersistor} + linking={{ config: { initialRouteName: 'SamplesList' } }} onReady={() => { void SplashScreen.hideAsync(); }} From 2728b8eb74379266a1b3e09851458e4ca5ce4655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Fedyna?= Date: Mon, 17 Aug 2026 10:48:29 +0200 Subject: [PATCH 5/5] feat: self review --- ...pPreferences.ts => useSetupPreferences.ts} | 3 +- example/src/RootNavigator.tsx | 7 +-- example/src/Samples/types.ts | 44 +------------------ example/src/index.tsx | 6 +-- 4 files changed, 6 insertions(+), 54 deletions(-) rename example/src/Preferences/{setupPreferences.ts => useSetupPreferences.ts} (96%) diff --git a/example/src/Preferences/setupPreferences.ts b/example/src/Preferences/useSetupPreferences.ts similarity index 96% rename from example/src/Preferences/setupPreferences.ts rename to example/src/Preferences/useSetupPreferences.ts index 01ce39220f..5313f9853d 100644 --- a/example/src/Preferences/setupPreferences.ts +++ b/example/src/Preferences/useSetupPreferences.ts @@ -1,4 +1,3 @@ -/* eslint-disable react-hooks/rules-of-hooks -- `setupPreferences` is a hook, named after its role at the root of the app */ import * as React from 'react'; import { I18nManager, Platform } from 'react-native'; @@ -36,7 +35,7 @@ export const navigationPersistor = { }, }; -export function setupPreferences() { +export function useSetupPreferences() { const [isReady, setIsReady] = React.useState(false); const [initialRtl] = React.useState(getInitialRtl); diff --git a/example/src/RootNavigator.tsx b/example/src/RootNavigator.tsx index cba65a5a3f..69df2388e1 100644 --- a/example/src/RootNavigator.tsx +++ b/example/src/RootNavigator.tsx @@ -30,7 +30,6 @@ function Header({ navigation, route, options, back }: NativeStackHeaderProps) { const searchAction = ( navigation.navigate('ExampleList')} /> ); @@ -40,11 +39,7 @@ function Header({ navigation, route, options, back }: NativeStackHeaderProps) { {back ? backAction : isIOS ? searchAction : null} {!isIOS && !back && searchAction} - + ); } diff --git a/example/src/Samples/types.ts b/example/src/Samples/types.ts index 96e4b179a9..7efa77a30d 100644 --- a/example/src/Samples/types.ts +++ b/example/src/Samples/types.ts @@ -1,47 +1,5 @@ -/** - * Every component exported by `react-native-paper`, so a sample can only be - * tagged with a name that actually exists. - */ -export type PaperComponentName = - | 'ActivityIndicator' - | 'Appbar' - | 'Avatar' - | 'Badge' - | 'Banner' - | 'BottomNavigation' - | 'Button' - | 'Card' - | 'Checkbox' - | 'Chip' - | 'DataTable' - | 'Dialog' - | 'Divider' - | 'Drawer' - | 'FAB' - | 'Icon' - | 'IconButton' - | 'List' - | 'Menu' - | 'Modal' - | 'Portal' - | 'ProgressBar' - | 'RadioButton' - | 'Searchbar' - | 'SegmentedButtons' - | 'Snackbar' - | 'Surface' - | 'Switch' - | 'Text' - | 'TextInput' - | 'ToggleButton' - | 'Tooltip' - | 'TouchableRipple'; - export type SampleConfig = { title: string; icon: string; - /** - * Components the sample is built from, in alphabetical order. - */ - components: PaperComponentName[]; + components: string[]; }; diff --git a/example/src/index.tsx b/example/src/index.tsx index c376daad44..bfaf5e4c56 100644 --- a/example/src/index.tsx +++ b/example/src/index.tsx @@ -10,8 +10,8 @@ import { PaperProvider } from 'react-native-paper'; import { PreferencesContext } from './Preferences/PreferencesContext'; import { navigationPersistor, - setupPreferences, -} from './Preferences/setupPreferences'; + useSetupPreferences, +} from './Preferences/useSetupPreferences'; import App from './RootNavigator'; import { CombinedDarkTheme, @@ -36,7 +36,7 @@ export default function PaperExample() { }); const { preferences, isReady, isDarkMode, direction, navigationKey } = - setupPreferences(); + useSetupPreferences(); if (!isReady || !fontsLoaded) { return null;