feat: add labels to tab buttons (#2611)

* feat: add text labels to bottom tabs
* fix: treat icon and text as single pressable for screen readers in bottom tabs
* feat: float AddObs button outside of bottom tab bar

Closes MOB-254

---------

Co-authored-by: Kirk van Gorkom <55742+kvangork@users.noreply.github.com>
This commit is contained in:
Ken-ichi
2025-01-21 11:20:48 -08:00
committed by GitHub
parent 47cf9f815a
commit 736eba5a52
15 changed files with 784 additions and 471 deletions

View File

@@ -76,7 +76,7 @@ const AddObsButton = (): React.Node => {
modal={addObsModal}
/>
<GradientButton
sizeClassName="w-[69px] h-[69px]"
sizeClassName="w-[69px] h-[69px] mb-[5px]"
onPress={isAllAddObsOptionsMode
? openModal
: navToARCamera}

View File

@@ -21,6 +21,8 @@ interface Props extends PropsWithChildren {
disabled?: boolean;
height?: number;
icon?: string;
// Only show the icon with all the same layout, don't make it a button
iconOnly?: boolean;
onPress: ( _event?: GestureResponderEvent ) => void;
// Inserts a white or colored view under the icon so an holes in the shape show as
// white
@@ -35,6 +37,15 @@ interface Props extends PropsWithChildren {
const MIN_ACCESSIBLE_DIM = 44;
const WRAPPER_STYLE: ViewStyle = {
alignItems: "center",
justifyContent: "center"
};
const CONTAINED_WRAPPER_STYLE: ViewStyle = {
borderRadius: 9999
};
// Similar to IconButton in react-native-paper, except this allows independent
// control over touchable area with `width` and `height` *and* the size of
// the icon with `size`
@@ -46,6 +57,7 @@ const INatIconButton = ( {
disabled = false,
height = MIN_ACCESSIBLE_DIM,
icon,
iconOnly,
onPress,
preventTransparency,
size = 18,
@@ -67,12 +79,12 @@ const INatIconButton = ( {
`Height cannot be less than ${MIN_ACCESSIBLE_DIM}. Use IconButton for smaller buttons.`
);
}
if ( !accessibilityLabel ) {
if ( !accessibilityLabel && !iconOnly ) {
throw new Error(
"Button needs an accessibility label"
);
}
const opacity = ( pressed: boolean ) => {
const getOpacity = React.useCallback( ( pressed: boolean ) => {
if ( disabled ) {
return 0.5;
}
@@ -80,7 +92,84 @@ const INatIconButton = ( {
return 0.95;
}
return 1;
};
}, [disabled] );
const wrapperStyle = React.useMemo( ( ) => ( [
{ width, height },
WRAPPER_STYLE,
mode === "contained" && {
backgroundColor: preventTransparency
? undefined
: backgroundColor,
...CONTAINED_WRAPPER_STYLE
},
style
] ), [
backgroundColor,
height,
mode,
preventTransparency,
style,
width
] );
const content = (
<View
className={classnames(
"relative",
// This degree of pixel pushing was meant for a ~22px icon, so it
// might have to be made relative, but it's barely noticeable for
// most icons
Platform.OS === "android"
? "top-[0.8px]"
: "left-[0.2px] top-[0.1px]"
)}
>
{ backgroundColor && preventTransparency && (
<View
// Position and size need to be dynamic
// eslint-disable-next-line react-native/no-inline-styles
style={{
opacity: disabled
? 0
: 1,
position: "absolute",
top: preventTransparency
? 2
: -2,
start: preventTransparency
? 2
: -2,
width: preventTransparency
? size - 4
: size + 4,
height: preventTransparency
? size - 4
: size + 4,
backgroundColor,
borderRadius: 9999
}}
/>
)}
{
children || (
<INatIcon
name={icon}
size={size}
color={String( color || colors?.darkGray )}
/>
)
}
</View>
);
if ( iconOnly ) {
return (
<View style={wrapperStyle} testID={testID}>
{ content }
</View>
);
}
return (
<Pressable
@@ -91,70 +180,12 @@ const INatIconButton = ( {
disabled={disabled}
onPress={onPress}
style={( { pressed } ) => [
{
opacity: opacity( pressed ),
width,
height,
justifyContent: "center",
alignItems: "center"
},
mode === "contained" && {
backgroundColor: preventTransparency
? undefined
: backgroundColor,
borderRadius: 9999
},
style
...wrapperStyle,
{ opacity: getOpacity( pressed ) }
]}
testID={testID}
>
<View
className={classnames(
"relative",
// This degree of pixel pushing was meant for a ~22px icon, so it
// might have to be made relative, but it's barely noticeable for
// most icons
Platform.OS === "android"
? "top-[0.8px]"
: "left-[0.2px] top-[0.1px]"
)}
>
{ backgroundColor && preventTransparency && (
<View
// Position and size need to be dynamic
// eslint-disable-next-line react-native/no-inline-styles
style={{
opacity: disabled
? 0
: 1,
position: "absolute",
top: preventTransparency
? 2
: -2,
start: preventTransparency
? 2
: -2,
width: preventTransparency
? size - 4
: size + 4,
height: preventTransparency
? size - 4
: size + 4,
backgroundColor,
borderRadius: 9999
}}
/>
)}
{
children || (
<INatIcon
name={icon}
size={size}
color={color || colors.darkGray}
/>
)
}
</View>
{ content }
</Pressable>
);
};

View File

@@ -722,6 +722,12 @@ Most-faved = Most faved
# created by the viewer. Should be 16 characters or fewer or it will be ellipsized.
MY-CONTENT--notifications = MY CONTENT
My-Observations = My Observations
# Label for the bottom tab that shows your observations. Feel free to be
# flexible in translating this to keep it as short as possible. "My
# Observations" would be our preference in English, but it won't really fit,
# so we went with "Me". You have about ~7-20 characters before it gets cut
# off on the smallest screen sizes.
My-Observations--bottom-tab = Me
Native = Native
Native-to-place = Native to { $place }
Navigates-to-AI-camera = Navigates to AI camera
@@ -785,7 +791,12 @@ NOTES = NOTES
# Label for section in ObsDetails with notes/description of observation
Notes = Notes
NOTIFICATIONS = NOTIFICATIONS
Notifications = Notifications
# Label for the bottom tab that shows notifications. Feel free to be flexible
# in translating this to keep it as short as possible. "Notifications" would
# be our preference in English, but it won't really fit, so we went
# with "Activity". You have about ~7-20 characters before it gets cut off on
# the smallest screen sizes.
Notifications--bottom-tab = Activity
# notification when someone adds a comment to your observation
notifications-user-added-comment-to-observation-by-you = <0>{ $userName }</0> added a comment to an observation by you
# notification when someone adds an identification to your observation
@@ -857,7 +868,6 @@ Once-you-create-and-upload-observations = Once you create & upload observations,
One-last-step = One last step!
# Adjective, as in geoprivacy
Open = Open
Open-drawer = Open drawer
OPEN-EMAIL = OPEN EMAIL
Open-menu = Open menu.
# Text for a button that opens the operating system Settings app

View File

@@ -432,6 +432,7 @@
"Most-faved": "Most faved",
"MY-CONTENT--notifications": "MY CONTENT",
"My-Observations": "My Observations",
"My-Observations--bottom-tab": "Me",
"Native": "Native",
"Native-to-place": "Native to { $place }",
"Navigates-to-AI-camera": "Navigates to AI camera",
@@ -475,7 +476,7 @@
"NOTES": "NOTES",
"Notes": "Notes",
"NOTIFICATIONS": "NOTIFICATIONS",
"Notifications": "Notifications",
"Notifications--bottom-tab": "Activity",
"notifications-user-added-comment-to-observation-by-you": "<0>{ $userName }</0> added a comment to an observation by you",
"notifications-user-added-identification-to-observation-by-you": "<0>{ $userName }</0> added an identification to an observation by you",
"notifications-user1-added-comment-to-observation-by-user2": "<0>{ $user1 }</0> added a comment to an observation by { $user2 }",
@@ -519,7 +520,6 @@
"Once-you-create-and-upload-observations": "Once you create & upload observations, other members of our community can add identifications to help your observations reach research grade.",
"One-last-step": "One last step!",
"Open": "Open",
"Open-drawer": "Open drawer",
"OPEN-EMAIL": "OPEN EMAIL",
"Open-menu": "Open menu.",
"OPEN-SETTINGS": "OPEN SETTINGS",

View File

@@ -722,6 +722,12 @@ Most-faved = Most faved
# created by the viewer. Should be 16 characters or fewer or it will be ellipsized.
MY-CONTENT--notifications = MY CONTENT
My-Observations = My Observations
# Label for the bottom tab that shows your observations. Feel free to be
# flexible in translating this to keep it as short as possible. "My
# Observations" would be our preference in English, but it won't really fit,
# so we went with "Me". You have about ~7-20 characters before it gets cut
# off on the smallest screen sizes.
My-Observations--bottom-tab = Me
Native = Native
Native-to-place = Native to { $place }
Navigates-to-AI-camera = Navigates to AI camera
@@ -785,7 +791,12 @@ NOTES = NOTES
# Label for section in ObsDetails with notes/description of observation
Notes = Notes
NOTIFICATIONS = NOTIFICATIONS
Notifications = Notifications
# Label for the bottom tab that shows notifications. Feel free to be flexible
# in translating this to keep it as short as possible. "Notifications" would
# be our preference in English, but it won't really fit, so we went
# with "Activity". You have about ~7-20 characters before it gets cut off on
# the smallest screen sizes.
Notifications--bottom-tab = Activity
# notification when someone adds a comment to your observation
notifications-user-added-comment-to-observation-by-you = <0>{ $userName }</0> added a comment to an observation by you
# notification when someone adds an identification to your observation
@@ -857,7 +868,6 @@ Once-you-create-and-upload-observations = Once you create & upload observations,
One-last-step = One last step!
# Adjective, as in geoprivacy
Open = Open
Open-drawer = Open drawer
OPEN-EMAIL = OPEN EMAIL
Open-menu = Open menu.
# Text for a button that opens the operating system Settings app

View File

@@ -23,14 +23,26 @@ type Props = {
const CustomTabBar = ( { tabs }: Props ): Node => {
const tabList = tabs.map( tab => <NavButton {...tab} key={tab.testID} /> );
tabList.splice( -2, 0, <AddObsButton key="AddObsButton" /> );
tabList.splice(
-2,
0,
// Absolutely position the AddObsButton so it can float outside of the tab
// bar
(
<View className="w-[69px] h-[60px] mx-3" key="CustomTabBar-AddObs">
<View className="absolute top-[-13px]">
<AddObsButton key="AddObsButton" />
</View>
</View>
)
);
const insets = useSafeAreaInsets( );
return (
<View
className={classNames(
"flex-row bg-white justify-evenly items-center p-1 m-0",
"flex-row bg-white justify-evenly p-1 m-0",
{ "pb-5": insets.bottom > 0 }
)}
style={DROP_SHADOW}

View File

@@ -43,7 +43,7 @@ const CustomTabBarContainer = ( { navigation }: Props ): Node => {
{
icon: "hamburger-menu",
testID: DRAWER_ID,
accessibilityLabel: t( "Open-drawer" ),
accessibilityLabel: t( "Menu" ),
accessibilityHint: t( "Opens-the-side-drawer-menu" ),
size: 32,
onPress: ( ) => {
@@ -66,7 +66,7 @@ const CustomTabBarContainer = ( { navigation }: Props ): Node => {
icon: "person",
userIconUri: User.uri( currentUser ),
testID: "NavButton.personIcon",
accessibilityLabel: t( "Observations" ),
accessibilityLabel: t( "My-Observations--bottom-tab" ),
accessibilityHint: t( "Navigates-to-your-observations" ),
size: 40,
onPress: ( ) => {
@@ -77,7 +77,7 @@ const CustomTabBarContainer = ( { navigation }: Props ): Node => {
{
icon: "notifications-bell",
testID: SCREEN_NAME_NOTIFICATIONS,
accessibilityLabel: t( "Notifications" ),
accessibilityLabel: t( "Notifications--bottom-tab" ),
accessibilityHint: t( "Navigates-to-notifications" ),
size: 32,
onPress: ( ) => {

View File

@@ -1,6 +1,6 @@
// @flow
import { INatIconButton, UserIcon } from "components/SharedComponents";
import { Pressable } from "components/styledComponents";
import { Body3, INatIconButton, UserIcon } from "components/SharedComponents";
import { Pressable, View } from "components/styledComponents";
import NotificationsIconContainer from "navigation/BottomTabNavigator/NotificationsIconContainer";
import * as React from "react";
import colors from "styles/tailwindColors";
@@ -28,67 +28,73 @@ const NavButton = ( {
active,
accessibilityLabel,
accessibilityHint,
accessibilityRole = "tab",
width = 44,
height = 44
}: Props ): React.Node => {
/* eslint-disable react/jsx-props-no-spreading */
const sharedProps = {
testID,
onPress,
accessibilityRole,
accessibilityLabel,
accessibilityHint,
accessibilityState: {
selected: active,
expanded: active,
disabled: false
},
width,
height
};
const notificationProps = {
testID,
onPress,
accessibilityRole,
accessibilityLabel,
accessibilityHint,
width,
height
};
let iconComponent;
if ( userIconUri ) {
return (
<Pressable
iconComponent = (
<View
className="flex items-center justify-center"
{...sharedProps}
>
<UserIcon uri={userIconUri} active={active} />
</Pressable>
</View>
);
}
if ( icon === "notifications-bell" ) {
return (
} else if ( icon === "notifications-bell" ) {
iconComponent = (
<NotificationsIconContainer
icon={icon}
size={size}
active={active}
{...notificationProps}
{...sharedProps}
/>
);
} else {
iconComponent = (
<INatIconButton
icon={icon}
iconOnly
color={active
? colors.inatGreen
: colors.darkGray}
size={size}
{...sharedProps}
/>
);
}
return (
<INatIconButton
icon={icon}
color={active
? colors.inatGreen
: colors.darkGray}
size={size}
{...sharedProps}
/>
<Pressable
className="flex-column items-center w-[20%] justify-end"
onPress={onPress}
key={`NavButton-${accessibilityLabel}`}
accessibilityLabel={accessibilityLabel}
accessibilityRole="tab"
accessibilityHint={accessibilityHint}
accessibilityState={{
selected: active,
disabled: false
}}
>
{iconComponent}
<Body3
numberOfLines={1}
className={active
? "text-inatGreen"
: "text-darkGray"}
maxFontSizeMultiplier={1.2}
>
{accessibilityLabel}
</Body3>
</Pressable>
);
};

View File

@@ -1,9 +1,7 @@
// @flow
import classnames from "classnames";
import { INatIcon, INatIconButton } from "components/SharedComponents";
import {
Pressable, View
} from "components/styledComponents";
import { View } from "components/styledComponents";
import * as React from "react";
import colors from "styles/tailwindColors";
@@ -11,11 +9,7 @@ type Props = {
unread: boolean,
icon: string,
testID: string,
onPress: Function,
active:boolean,
accessibilityLabel: string,
accessibilityRole?: string,
accessibilityHint?: string,
size: number,
width?: number,
height?: number
@@ -26,33 +20,20 @@ const NotificationsIcon = ( {
testID,
size,
icon,
onPress,
active,
accessibilityLabel,
accessibilityHint,
accessibilityRole = "tab",
width,
height
}: Props ): React.Node => {
/* eslint-disable react/jsx-props-no-spreading */
const sharedProps = {
testID,
onPress,
accessibilityRole,
accessibilityLabel,
accessibilityHint,
accessibilityState: {
selected: active,
expanded: active,
disabled: false
},
width,
height
};
if ( unread ) {
return (
<Pressable
<View
className="flex items-center justify-center"
{...sharedProps}
>
@@ -88,7 +69,7 @@ const NotificationsIcon = ( {
)}
/>
</View>
</Pressable>
</View>
);
}
@@ -100,6 +81,7 @@ const NotificationsIcon = ( {
: colors.darkGray}
size={size}
{...sharedProps}
iconOnly
/>
);
};

View File

@@ -13,11 +13,7 @@ import useStore from "stores/useStore";
type Props = {
testID: string,
icon: string,
onPress: Function,
active:boolean,
accessibilityLabel: string,
accessibilityRole?: string,
accessibilityHint?: string,
size: number,
width?: number,
height?: number
@@ -27,11 +23,7 @@ const NotificationsIconContainer = ( {
testID,
size,
icon,
onPress,
active,
accessibilityLabel,
accessibilityHint,
accessibilityRole = "tab",
width,
height
}: Props ): Node => {
@@ -73,10 +65,6 @@ const NotificationsIconContainer = ( {
active={active}
size={size}
testID={testID}
onPress={onPress}
accessibilityRole={accessibilityRole}
accessibilityLabel={accessibilityLabel}
accessibilityHint={accessibilityHint}
width={width}
height={height}
/>

View File

@@ -102,7 +102,7 @@ async function navigateToRootExplore( ) {
const welcomeBack = await screen.findByText( /Welcome back/ );
await waitFor( ( ) => expect( welcomeBack ).toBeVisible( ) );
const tabBar = await screen.findByTestId( "CustomTabBar" );
const exploreButton = await within( tabBar ).findByLabelText( "Explore" );
const exploreButton = await within( tabBar ).findByText( "Explore" );
await actor.press( exploreButton );
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -36,14 +36,18 @@ exports[`INatIconButton renders correctly 1`] = `
style={
[
{
"alignItems": "center",
"height": 44,
"justifyContent": "center",
"opacity": 1,
"width": 44,
},
{
"alignItems": "center",
"justifyContent": "center",
},
false,
undefined,
{
"opacity": 1,
},
]
}
>

View File

@@ -447,14 +447,18 @@ exports[`UploadStatus displays start icon when upload is unsynced and not queued
style={
[
{
"alignItems": "center",
"height": 44,
"justifyContent": "center",
"opacity": 1,
"width": 44,
},
{
"alignItems": "center",
"justifyContent": "center",
},
false,
undefined,
{
"opacity": 1,
},
]
}
testID="UploadIcon.start.undefined"

View File

@@ -460,14 +460,18 @@ exports[`TaxonResult should render correctly 1`] = `
style={
[
{
"alignItems": "center",
"height": 44,
"justifyContent": "center",
"opacity": 1,
"width": 44,
},
{
"alignItems": "center",
"justifyContent": "center",
},
false,
undefined,
{
"opacity": 1,
},
]
}
>
@@ -546,18 +550,22 @@ exports[`TaxonResult should render correctly 1`] = `
style={
[
{
"alignItems": "center",
"height": 44,
"justifyContent": "center",
"opacity": 1,
"width": 44,
},
{
"alignItems": "center",
"justifyContent": "center",
},
false,
[
{
"marginLeft": 8,
},
],
{
"opacity": 1,
},
]
}
testID="undefined.checkmark"