Skip to content

Commit

Permalink
Merge pull request #188 from department-of-veterans-affairs/feature/1…
Browse files Browse the repository at this point in the history
…68-roettger-LinkComponent-AddInlineVariant

[Feature] Link Component - Add Inline Variant Support
  • Loading branch information
TimRoe authored Feb 26, 2024
2 parents a61d29f + 8d8a9d8 commit 359a65d
Show file tree
Hide file tree
Showing 4 changed files with 182 additions and 32 deletions.
20 changes: 20 additions & 0 deletions packages/components/src/components/Link/Link.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,26 @@ export const Directions: Story = {
},
}

const paragraphText: LinkProps['paragraphText'] = [
// @ts-ignore: TS being wrong and thinking all should be LinkProps and none normalText
{text: 'A sentence may include a '},
{text: 'link that opens in a webview', type: 'custom', onPress: () => {null}, a11yLabel: 'a11y override' },
// @ts-ignore: TS being wrong and thinking all should be LinkProps and none normalText
{text: ' or a '},
{text: 'link that opens in an external app', type: 'url', url: 'https://department-of-veterans-affairs.github.io/va-mobile-app/design/intro'},
// @ts-ignore: TS being wrong and thinking all should be LinkProps and none normalText
{text: '.'}
]

export const Inline: Story = {
args: {
text: '',
onPress: undefined, // Storybook sends a truthy function shell otherwise
type: 'inline',
paragraphText: paragraphText,
},
}

export const Phone: Story = {
args: {
text: 'Call number',
Expand Down
161 changes: 130 additions & 31 deletions packages/components/src/components/Link/Link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,31 @@ import {
PressableProps,
PressableStateCallbackType,
Text,
TextProps,
TextStyle,
View,
ViewProps,
} from 'react-native'
import React, { FC } from 'react'
import React, { FC, useState } from 'react'

import {
CalendarData,
FormDirectionsUrl,
LocationData,
OnPressCalendar,
isIOS,
leaveAppPromptText,
useExternalLink,
} from '../../utils/OSfunctions'
import { Icon, IconProps } from '../Icon/Icon'
import { useColorScheme } from '../../utils'
import { useColorScheme, useIsScreenReaderEnabled } from '../../utils'

// Convenience type to default type-specific props to not existing/being optional
type nullTypeSpecifics = {
calendarData?: never
locationData?: never
/** Optional onPress override logic */
onPress?: () => void
paragraphText?: never
phoneNumber?: never
textNumber?: never
TTYnumber?: never
Expand Down Expand Up @@ -59,18 +61,15 @@ type directions = Omit<nullTypeSpecifics, 'locationData'> & {
locationData: LocationData
}

// TODO: Ticket 168 created for in-line link
// See lines 373-390 for app code:
// src/screens/BenefitsScreen/ClaimsScreen/AppealDetailsScreen/AppealStatus/AppealCurrentStatus/AppealCurrentStatus.tsx
// type normalText = {
// text: string
// textA11y: string
// }
type normalText = {
text: string
textA11y?: string
}

// type inLineLink = {
// type: 'in line link'
// paragraphText: normalText[] | LinkProps[]
// }
type inline = Omit<nullTypeSpecifics, 'paragraphText'> & {
type: 'inline'
paragraphText: normalText[] | LinkProps[]
}

type text = Omit<nullTypeSpecifics, 'textNumber'> & {
type: 'text'
Expand All @@ -82,7 +81,15 @@ type url = Omit<nullTypeSpecifics, 'url'> & {
url: string
}

type linkTypes = calendar | call | callTTY | custom | directions | text | url
type linkTypes =
| calendar
| call
| callTTY
| custom
| directions
| inline
| text
| url

// TODO: Ticket 170 created to revisit adding analytics after calendar support added/or deemed infeasible
// type analytics = {
Expand All @@ -109,6 +116,10 @@ export type LinkProps = linkTypes & {
promptText?: leaveAppPromptText
/** Optional analytics event logging */
// analytics?: analytics
/** Internally used by 'inline' type. Not recommended for consumer use, but
* available to manually insert a link into a paragraph. True builds link
* component with RN Text instead of Pressable for improved wrapping behavior */
inlineSingle?: boolean
/** Optional TestID */
testID?: string
}
Expand All @@ -124,10 +135,12 @@ export const Link: FC<LinkProps> = ({
a11yHint,
promptText,
// analytics,
inlineSingle,
testID,
// Type-specific props
calendarData,
locationData,
paragraphText,
phoneNumber,
textNumber,
TTYnumber,
Expand Down Expand Up @@ -172,6 +185,8 @@ export const Link: FC<LinkProps> = ({
launchExternalLink(directions, promptText)
}
break
case 'inline':
return <InlineLink paragraphText={paragraphText} />
case 'text':
icon = icon ? icon : { name: 'Text' }
_onPress = async (): Promise<void> => {
Expand Down Expand Up @@ -201,22 +216,34 @@ export const Link: FC<LinkProps> = ({
linkColor = isDarkMode ? Colors.uswdsBlueVivid30 : Colors.primary
}

const pressableProps: PressableProps = {
onPress: onPress ? onPress : _onPress,
const iconDisplay =
icon === 'no icon' ? null : inlineSingle ? (
<>
<Icon fill={linkColor} {...icon} />
{/* Space forms padding prior to link text */}
<Text> </Text>
</>
) : (
<View style={{ marginRight: 5 }}>
<Icon fill={linkColor} {...icon} />
</View>
)

const a11yProps: TextProps = {
'aria-label': a11yLabel,
accessibilityHint: a11yHint,
role: 'link',
accessible: true,
}

const viewStyle: ViewProps['style'] = {
alignItems: 'center',
flexDirection: 'row',
}

const innerViewStyle: ViewProps['style'] = {
flexShrink: 1,
marginLeft: icon === 'no icon' ? 0 : 5,
const pressableProps: PressableProps = {
onPress: onPress ? onPress : _onPress,
...a11yProps,
style: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
}

const getTextStyle = (pressed: boolean): TextStyle => {
Expand All @@ -241,16 +268,88 @@ export const Link: FC<LinkProps> = ({
return { ...(pressed ? pressedFont : regularFont), ...textStyle }
}

const [pressStyle, setPressStyle] = useState(false)
if (inlineSingle) {
const onPressProps: TextProps = {
onPressIn: () => {
setPressStyle(true)
},
onPress: onPress ? onPress : _onPress,
onPressOut: () => {
setPressStyle(false)
},
}
return (
<Text>
{iconDisplay}
<Text {...onPressProps} {...a11yProps} style={getTextStyle(pressStyle)}>
{text}
</Text>
</Text>
)
}

return (
<Pressable {...pressableProps} testID={testID}>
{({ pressed }: PressableStateCallbackType) => (
<View style={viewStyle}>
{icon === 'no icon' ? null : <Icon fill={linkColor} {...icon} />}
<View style={innerViewStyle}>
<Text style={getTextStyle(pressed)}>{text}</Text>
</View>
</View>
<>
{iconDisplay}
<Text style={getTextStyle(pressed)}>{text}</Text>
</>
)}
</Pressable>
)
}

const ParagraphText: FC<normalText> = ({ text, textA11y }) => {
const colorScheme = useColorScheme()
const isDarkMode = colorScheme === 'dark'

// TODO: Replace with typography tokens
const regularFont: TextStyle = {
fontFamily: 'SourceSansPro-Regular',
fontSize: 20,
lineHeight: 30,
color: isDarkMode ? Colors.grayLightest : Colors.grayDark,
}

return (
<Text style={regularFont} accessible={true} aria-label={textA11y}>
{text}
</Text>
)
}

const InlineLink: FC<Pick<inline, 'paragraphText'>> = ({ paragraphText }) => {
const screenReaderEnabled = useIsScreenReaderEnabled()
if (screenReaderEnabled && isIOS) {
return (
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
{paragraphText.map((item, index) => {
// key included as this is a list of React components and the renderer worries about losing track
if ('type' in item) {
// Link if type prop exists
item.inlineSingle = undefined
return <Link {...item} key={index} />
} else {
return <ParagraphText {...item} key={index} />
}
})}
</View>
)
}
return (
<Text>
{paragraphText.map((item, index) => {
// key included as this is a list of React components and the renderer worries about losing track
if ('type' in item) {
// Link if type prop exists
item.inlineSingle = true
return <Link {...item} key={index} />
} else {
return <ParagraphText {...item} key={index} />
}
})}
</Text>
)
}
2 changes: 1 addition & 1 deletion packages/components/src/utils/OSfunctions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { Alert, Linking, Platform } from 'react-native'
import { useTranslation } from 'react-i18next'

const isIOS = Platform.OS === 'ios'
export const isIOS = Platform.OS === 'ios'

type address = {
street: string
Expand Down
31 changes: 31 additions & 0 deletions packages/components/src/utils/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import {
AccessibilityInfo,
ColorSchemeName,
PressableStateCallbackType,
useColorScheme as RNUseColorScheme,
StyleProp,
ViewStyle,
} from 'react-native'
import { useEffect, useState } from 'react'

/** Handles return of color scheme based on platform */
export function useColorScheme(): ColorSchemeName {
Expand All @@ -22,6 +24,35 @@ export function useColorScheme(): ColorSchemeName {
}
}

/**
* Hook to monitor screen reader status
* @returns True when the screen reader is on, else false
*/
export function useIsScreenReaderEnabled(): boolean {
const [screenReaderEnabled, setScreenReaderEnabled] = useState(false)

useEffect(() => {
// Function to update state based on the screen reader status
const updateScreenReaderStatus = (isEnabled: boolean) => {
setScreenReaderEnabled(isEnabled);
};

// Initiate with current screen reader status
AccessibilityInfo.isScreenReaderEnabled().then(updateScreenReaderStatus)

// Subscribe to screen reader status changes
const subscription = AccessibilityInfo.addEventListener(
'screenReaderChanged',
updateScreenReaderStatus
)

// Cleanup subscription on component unmount
return () => subscription.remove()
}, [screenReaderEnabled])

return screenReaderEnabled
}

/**
* Convenience function for handling TouchableOpacity styling on Pressable component
* @param styles - RN styling to apply to Pressable component besides on press opacity
Expand Down

0 comments on commit 359a65d

Please sign in to comment.