diff --git a/packages/components/src/components/Alert/Alert.stories.tsx b/packages/components/src/components/Alert/Alert.stories.tsx index ee0a5339..e253b20b 100644 --- a/packages/components/src/components/Alert/Alert.stories.tsx +++ b/packages/components/src/components/Alert/Alert.stories.tsx @@ -59,3 +59,25 @@ export const Info: Story = { }, }, } + +export const Expandable: Story = { + args: { + variant: 'info', + header: 'Header', + description: 'Description', + children: children, + expandable: true, + primaryButton: { + label: 'Button Text', + onPress: () => { + null + }, + }, + secondaryButton: { + label: 'Button Text', + onPress: () => { + null + }, + }, + }, +} diff --git a/packages/components/src/components/Alert/Alert.tsx b/packages/components/src/components/Alert/Alert.tsx index 7ba2717a..d8ee8e40 100644 --- a/packages/components/src/components/Alert/Alert.tsx +++ b/packages/components/src/components/Alert/Alert.tsx @@ -1,12 +1,14 @@ import { Colors } from '@department-of-veterans-affairs/mobile-tokens' -// import { HapticFeedbackTypes } from 'react-native-haptic-feedback' -import { Text, TextStyle, View, ViewStyle } from 'react-native' -// ^ ScrollView, -import React, { FC } from 'react' -// ^ , RefObject, useEffect, useState - -// import { triggerHaptic } from 'utils/haptics' -// import { useAutoScrollToElement } from 'utils/hooks' +import { + Insets, + Pressable, + Text, + TextStyle, + View, + ViewStyle, + useWindowDimensions, +} from 'react-native' +import React, { FC, useState } from 'react' import { BaseColor, Spacer, useColorScheme } from '../../utils' import { Button, ButtonProps, ButtonVariants } from '../Button/Button' @@ -18,8 +20,6 @@ export const AlertContentColor = BaseColor export type AlertProps = { /** Alert variant */ variant: 'info' | 'success' | 'warning' | 'error' - /** Optional header text */ - header?: string /** Optional a11y override for header */ headerA11yLabel?: string /** Optional description text */ @@ -33,13 +33,25 @@ export type AlertProps = { primaryButton?: ButtonProps /** Optional secondary action button */ secondaryButton?: ButtonProps - /** Optional boolean for determining when to focus on error alert boxes (e.g. onSaveClicked). */ - // focusOnError?: boolean - /** Optional ref for the parent scroll view. Used for scrolling to error alert boxes. */ - // scrollViewRef?: RefObject - /** optional testID */ + /** Optional testID */ testId?: string -} +} & ( + | { + /** True to make the Alert expandable */ + expandable: true + /** Header text. Required when Alert is expandable */ + header: string + /** True if Alert should start expanded. Defaults to false */ + initializeExpanded?: boolean + } + | { + /** True to make the Alert expandable */ + expandable?: false + /** Header text. Optional when Alert is not expandable */ + header?: string + initializeExpanded?: never + } +) /** * Work in progress: @@ -52,42 +64,27 @@ export const Alert: FC = ({ description, descriptionA11yLabel, children, + expandable, + initializeExpanded, primaryButton, secondaryButton, - // focusOnError = true, - // scrollViewRef, testId, }) => { const colorScheme = useColorScheme() + const fontScale = useWindowDimensions().fontScale const isDarkMode = colorScheme === 'dark' - // const [scrollRef, viewRef, scrollToAlert] = useAutoScrollToElement() - // const [shouldFocus, setShouldFocus] = useState(true) - - // useEffect(() => { - // if ( - // variant === 'error' && - // scrollViewRef?.current && - // (header || description) - // ) { - // scrollRef.current = scrollViewRef.current - // scrollToAlert(-boxPadding) - // } - // setShouldFocus(focusOnError) - // }, [ - // variant, - // header, - // description, - // focusOnError, - // scrollRef, - // scrollToAlert, - // scrollViewRef, - // ]) + const [expanded, setExpanded] = useState( + expandable ? initializeExpanded : true, + ) // TODO: Replace with sizing/dimension tokens const Sizing = { _8: 8, + _10: 10, _12: 12, + _16: 16, _20: 20, + _24: 24, _30: 30, } const contentColor = AlertContentColor() @@ -151,32 +148,52 @@ export const Alert: FC = ({ borderLeftWidth: Sizing._8, padding: Sizing._20, paddingLeft: Sizing._12, // Adds with borderLeftWidth for 20 - width: '100%' // Ensure Alert fills horizontal space, regardless of flexing content + width: '100%', // Ensure Alert fills horizontal space, regardless of flexing content } const iconViewStyle: ViewStyle = { flexDirection: 'row', // Below keeps icon aligned with first row of text, centered, and scalable alignSelf: 'flex-start', - minHeight: Sizing._30, + minHeight: Sizing._30 * fontScale, alignItems: 'center', justifyContent: 'center', } const iconDisplay = ( - + ) - // const vibrate = (): void => { - // if (variant === 'error') { - // triggerHaptic(HapticFeedbackTypes.notificationError) - // } else if (variant === 'warning') { - // triggerHaptic(HapticFeedbackTypes.notificationWarning) - // } - // } + const expandIconProps: IconProps = { + fill: contentColor, + width: Sizing._16, + height: Sizing._16, + maxWidth: Sizing._24, + name: expanded ? 'ChevronUp' : 'ChevronDown', + } + + const expandableIcon = ( + + + + + ) + + /** + * When an alert is expandable, the content should have additional padding on + * the right to appear within the expandable icon. Since the expandable icon + * has a maxWidth, this hidden icon matches the spacing of the icon insteading + * instead of adding a with a calculated value. + */ + const spacerIcon = ( + + + + + ) // TODO: Replace with typography tokens const headerFont: TextStyle = { @@ -194,6 +211,46 @@ export const Alert: FC = ({ lineHeight: 30, } + const _header = () => { + if (!header) return null + + const headerText = {header} + const a11yLabel = headerA11yLabel || header + const hitSlop: Insets = { + // left border + left padding + spacer + icon width + left: Sizing._8 + Sizing._12 + Sizing._10 + Sizing._24, + top: Sizing._20, + // bottom spacing changes depending on expanded state + bottom: expanded ? Sizing._10 : Sizing._20, + right: Sizing._20, + } + + /** + * Wrap header text and expand icon in Pressable if the Alert is expandable + * Otherwise wrap in View with accessibility props + */ + if (expandable) { + return ( + setExpanded(!expanded)} + role="tab" + aria-expanded={expanded} + aria-label={a11yLabel} + hitSlop={hitSlop} + style={{ flexDirection: 'row' }}> + {headerText} + {expandableIcon} + + ) + } + + return ( + + {headerText} + + ) + } + const _primaryButton = () => { if (!primaryButton) return null @@ -225,35 +282,39 @@ export const Alert: FC = ({ } return ( - + {iconDisplay} - {header ? ( - - {header} - - ) : null} - {header && (description || children) ? : null} - {description ? ( - - {description} + {_header()} + {expanded && ( + + + {header && (description || children) ? : null} + {description ? ( + + {description} + + ) : null} + {description && children ? : null} + {children} + + {expandable && spacerIcon} - ) : null} - {description && children ? : null} - {children} - {/* {shouldFocus && vibrate()} */} + )} - {_primaryButton()} - {_secondaryButton()} + {expanded && ( + <> + {_primaryButton()} + {_secondaryButton()} + + )} ) } diff --git a/packages/components/src/index.tsx b/packages/components/src/index.tsx index c8dfa04a..f7931a52 100644 --- a/packages/components/src/index.tsx +++ b/packages/components/src/index.tsx @@ -7,6 +7,7 @@ if (expoApp && App.initiateExpo) { } // Export components here so they are exported through npm +export { Alert } from './components/Alert/Alert' export { Button, ButtonVariants } from './components/Button/Button' export { Icon } from './components/Icon/Icon' export { Link } from './components/Link/Link'