diff options
Diffstat (limited to 'src/components')
-rw-r--r-- | src/components/Dialog/context.ts | 10 | ||||
-rw-r--r-- | src/components/Dialog/index.tsx | 131 | ||||
-rw-r--r-- | src/components/Dialog/index.web.tsx | 57 | ||||
-rw-r--r-- | src/components/Dialog/types.ts | 27 | ||||
-rw-r--r-- | src/components/Link.tsx | 86 | ||||
-rw-r--r-- | src/components/Prompt.tsx | 2 | ||||
-rw-r--r-- | src/components/RichText.tsx | 27 | ||||
-rw-r--r-- | src/components/Typography.tsx | 38 | ||||
-rw-r--r-- | src/components/icons/Times.tsx | 5 |
9 files changed, 218 insertions, 165 deletions
diff --git a/src/components/Dialog/context.ts b/src/components/Dialog/context.ts index b28b9f5a2..f0c7c983a 100644 --- a/src/components/Dialog/context.ts +++ b/src/components/Dialog/context.ts @@ -1,7 +1,11 @@ import React from 'react' import {useDialogStateContext} from '#/state/dialogs' -import {DialogContextProps, DialogControlProps} from '#/components/Dialog/types' +import { + DialogContextProps, + DialogControlProps, + DialogOuterProps, +} from '#/components/Dialog/types' export const Context = React.createContext<DialogContextProps>({ close: () => {}, @@ -11,7 +15,7 @@ export function useDialogContext() { return React.useContext(Context) } -export function useDialogControl() { +export function useDialogControl(): DialogOuterProps['control'] { const id = React.useId() const control = React.useRef<DialogControlProps>({ open: () => {}, @@ -30,6 +34,6 @@ export function useDialogControl() { return { ref: control, open: () => control.current.open(), - close: () => control.current.close(), + close: cb => control.current.close(cb), } } diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index 9132e68de..27f43afd3 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -8,7 +8,7 @@ import BottomSheet, { } from '@gorhom/bottom-sheet' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {useTheme, atoms as a} from '#/alf' +import {useTheme, atoms as a, flatten} from '#/alf' import {Portal} from '#/components/Portal' import {createInput} from '#/components/forms/TextField' @@ -35,12 +35,30 @@ export function Outer({ const sheetOptions = nativeOptions?.sheet || {} const hasSnapPoints = !!sheetOptions.snapPoints const insets = useSafeAreaInsets() + const closeCallback = React.useRef<() => void>() - const open = React.useCallback<DialogControlProps['open']>((i = 0) => { - sheet.current?.snapToIndex(i) - }, []) + /* + * Used to manage open/closed, but index is otherwise handled internally by `BottomSheet` + */ + const [openIndex, setOpenIndex] = React.useState(-1) + + /* + * `openIndex` is the index of the snap point to open the bottom sheet to. If >0, the bottom sheet is open. + */ + const isOpen = openIndex > -1 + + const open = React.useCallback<DialogControlProps['open']>( + ({index} = {}) => { + // can be set to any index of `snapPoints`, but `0` is the first i.e. "open" + setOpenIndex(index || 0) + }, + [setOpenIndex], + ) - const close = React.useCallback(() => { + const close = React.useCallback<DialogControlProps['close']>(cb => { + if (cb) { + closeCallback.current = cb + } sheet.current?.close() }, []) @@ -56,78 +74,85 @@ export function Outer({ const onChange = React.useCallback( (index: number) => { if (index === -1) { + closeCallback.current?.() + closeCallback.current = undefined onClose?.() + setOpenIndex(-1) } }, - [onClose], + [onClose, setOpenIndex], ) const context = React.useMemo(() => ({close}), [close]) return ( - <Portal> - <BottomSheet - enableDynamicSizing={!hasSnapPoints} - enablePanDownToClose - keyboardBehavior="interactive" - android_keyboardInputMode="adjustResize" - keyboardBlurBehavior="restore" - topInset={insets.top} - {...sheetOptions} - ref={sheet} - index={-1} - backgroundStyle={{backgroundColor: 'transparent'}} - backdropComponent={props => ( - <BottomSheetBackdrop - opacity={0.4} - appearsOnIndex={0} - disappearsOnIndex={-1} - {...props} - /> - )} - handleIndicatorStyle={{backgroundColor: t.palette.primary_500}} - handleStyle={{display: 'none'}} - onChange={onChange}> - <Context.Provider value={context}> - <View - style={[ - a.absolute, - a.inset_0, - t.atoms.bg, - { - borderTopLeftRadius: 40, - borderTopRightRadius: 40, - height: Dimensions.get('window').height * 2, - }, - ]} - /> - {children} - </Context.Provider> - </BottomSheet> - </Portal> + isOpen && ( + <Portal> + <BottomSheet + enableDynamicSizing={!hasSnapPoints} + enablePanDownToClose + keyboardBehavior="interactive" + android_keyboardInputMode="adjustResize" + keyboardBlurBehavior="restore" + topInset={insets.top} + {...sheetOptions} + snapPoints={sheetOptions.snapPoints || ['100%']} + ref={sheet} + index={openIndex} + backgroundStyle={{backgroundColor: 'transparent'}} + backdropComponent={props => ( + <BottomSheetBackdrop + opacity={0.4} + appearsOnIndex={0} + disappearsOnIndex={-1} + {...props} + style={[flatten(props.style), t.atoms.bg_contrast_300]} + /> + )} + handleIndicatorStyle={{backgroundColor: t.palette.primary_500}} + handleStyle={{display: 'none'}} + onChange={onChange}> + <Context.Provider value={context}> + <View + style={[ + a.absolute, + a.inset_0, + t.atoms.bg, + { + borderTopLeftRadius: 40, + borderTopRightRadius: 40, + height: Dimensions.get('window').height * 2, + }, + ]} + /> + {children} + </Context.Provider> + </BottomSheet> + </Portal> + ) ) } -// TODO a11y props here, or is that handled by the sheet? -export function Inner(props: DialogInnerProps) { +export function Inner({children, style}: DialogInnerProps) { const insets = useSafeAreaInsets() return ( <BottomSheetView style={[ - a.p_lg, + a.p_xl, { paddingTop: 40, borderTopLeftRadius: 40, borderTopRightRadius: 40, paddingBottom: insets.bottom + a.pb_5xl.paddingBottom, }, + flatten(style), ]}> - {props.children} + {children} </BottomSheetView> ) } -export function ScrollableInner(props: DialogInnerProps) { +export function ScrollableInner({children, style}: DialogInnerProps) { const insets = useSafeAreaInsets() return ( <BottomSheetScrollView @@ -136,13 +161,15 @@ export function ScrollableInner(props: DialogInnerProps) { style={[ a.flex_1, // main diff is this a.p_xl, + a.h_full, { paddingTop: 40, borderTopLeftRadius: 40, borderTopRightRadius: 40, }, + flatten(style), ]}> - {props.children} + {children} <View style={{height: insets.bottom + a.pt_5xl.paddingTop}} /> </BottomSheetScrollView> ) diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx index 305c00e97..79441fb5e 100644 --- a/src/components/Dialog/index.web.tsx +++ b/src/components/Dialog/index.web.tsx @@ -5,11 +5,13 @@ import Animated, {FadeInDown, FadeIn} from 'react-native-reanimated' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useTheme, atoms as a, useBreakpoints, web} from '#/alf' +import {useTheme, atoms as a, useBreakpoints, web, flatten} from '#/alf' import {Portal} from '#/components/Portal' import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types' import {Context} from '#/components/Dialog/context' +import {Button, ButtonIcon} from '#/components/Button' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' export {useDialogControl, useDialogContext} from '#/components/Dialog/context' export * from '#/components/Dialog/types' @@ -18,9 +20,9 @@ export {Input} from '#/components/forms/TextField' const stopPropagation = (e: any) => e.stopPropagation() export function Outer({ + children, control, onClose, - children, }: React.PropsWithChildren<DialogOuterProps>) { const {_} = useLingui() const t = useTheme() @@ -147,7 +149,7 @@ export function Inner({ a.rounded_md, a.w_full, a.border, - gtMobile ? a.p_xl : a.p_lg, + gtMobile ? a.p_2xl : a.p_xl, t.atoms.bg, { maxWidth: 600, @@ -156,7 +158,7 @@ export function Inner({ shadowOpacity: t.name === 'light' ? 0.1 : 0.4, shadowRadius: 30, }, - ...(Array.isArray(style) ? style : [style || {}]), + flatten(style), ]}> {children} </Animated.View> @@ -170,25 +172,28 @@ export function Handle() { return null } -/** - * TODO(eric) unused rn - */ -// export function Close() { -// const {_} = useLingui() -// const t = useTheme() -// const {close} = useDialogContext() -// return ( -// <View -// style={[ -// a.absolute, -// a.z_10, -// { -// top: a.pt_lg.paddingTop, -// right: a.pr_lg.paddingRight, -// }, -// ]}> -// <Button onPress={close} label={_(msg`Close active dialog`)}> -// </Button> -// </View> -// ) -// } +export function Close() { + const {_} = useLingui() + const {close} = React.useContext(Context) + return ( + <View + style={[ + a.absolute, + a.z_10, + { + top: a.pt_md.paddingTop, + right: a.pr_md.paddingRight, + }, + ]}> + <Button + size="small" + variant="ghost" + color="primary" + shape="round" + onPress={close} + label={_(msg`Close active dialog`)}> + <ButtonIcon icon={X} size="md" /> + </Button> + </View> + ) +} diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts index d36784183..75ba825ac 100644 --- a/src/components/Dialog/types.ts +++ b/src/components/Dialog/types.ts @@ -1,24 +1,34 @@ import React from 'react' -import type {ViewStyle, AccessibilityProps} from 'react-native' +import type {AccessibilityProps} from 'react-native' import {BottomSheetProps} from '@gorhom/bottom-sheet' +import {ViewStyleProp} from '#/alf' + type A11yProps = Required<AccessibilityProps> export type DialogContextProps = { close: () => void } +export type DialogControlOpenOptions = { + /** + * NATIVE ONLY + * + * Optional index of the snap point to open the bottom sheet to. Defaults to + * 0, which is the first snap point (i.e. "open"). + */ + index?: number +} + export type DialogControlProps = { - open: (index?: number) => void - close: () => void + open: (options?: DialogControlOpenOptions) => void + close: (callback?: () => void) => void } export type DialogOuterProps = { control: { ref: React.RefObject<DialogControlProps> - open: (index?: number) => void - close: () => void - } + } & DialogControlProps onClose?: () => void nativeOptions?: { sheet?: Omit<BottomSheetProps, 'children'> @@ -26,10 +36,7 @@ export type DialogOuterProps = { webOptions?: {} } -type DialogInnerPropsBase<T> = React.PropsWithChildren<{ - style?: ViewStyle -}> & - T +type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> & T export type DialogInnerProps = | DialogInnerPropsBase<{ label?: undefined diff --git a/src/components/Link.tsx b/src/components/Link.tsx index afd30b5ee..593b0863a 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -1,9 +1,5 @@ import React from 'react' -import { - GestureResponderEvent, - Linking, - TouchableWithoutFeedback, -} from 'react-native' +import {GestureResponderEvent, Linking} from 'react-native' import { useLinkProps, useNavigation, @@ -23,7 +19,7 @@ import { } from '#/lib/strings/url-helpers' import {useModalControls} from '#/state/modals' import {router} from '#/routes' -import {Text} from '#/components/Typography' +import {Text, TextProps} from '#/components/Typography' /** * Only available within a `Link`, since that inherits from `Button`. @@ -55,11 +51,12 @@ type BaseLinkProps = Pick< warnOnMismatchingTextChild?: boolean /** - * Callback for when the link is pressed. + * Callback for when the link is pressed. Prevent default and return `false` + * to exit early and prevent navigation. * * DO NOT use this for navigation, that's what the `to` prop is for. */ - onPress?: (e: GestureResponderEvent) => void + onPress?: (e: GestureResponderEvent) => void | false /** * Web-only attribute. Sets `download` attr on web. @@ -86,7 +83,9 @@ export function useLink({ const onPress = React.useCallback( (e: GestureResponderEvent) => { - outerOnPress?.(e) + const exitEarlyIfFalse = outerOnPress?.(e) + + if (exitEarlyIfFalse === false) return const requiresWarning = Boolean( warnOnMismatchingTextChild && @@ -217,7 +216,7 @@ export function Link({ } export type InlineLinkProps = React.PropsWithChildren< - BaseLinkProps & TextStyleProp + BaseLinkProps & TextStyleProp & Pick<TextProps, 'selectable'> > export function InlineLink({ @@ -228,6 +227,7 @@ export function InlineLink({ style, onPress: outerOnPress, download, + selectable, ...rest }: InlineLinkProps) { const t = useTheme() @@ -253,43 +253,41 @@ export function InlineLink({ const flattenedStyle = flatten(style) return ( - <TouchableWithoutFeedback - accessibilityRole="button" + <Text + selectable={selectable} + label={href} + {...rest} + style={[ + {color: t.palette.primary_500}, + (hovered || focused || pressed) && { + outline: 0, + textDecorationLine: 'underline', + textDecorationColor: flattenedStyle.color ?? t.palette.primary_500, + }, + flattenedStyle, + ]} + role="link" onPress={download ? undefined : onPress} onPressIn={onPressIn} onPressOut={onPressOut} onFocus={onFocus} - onBlur={onBlur}> - <Text - label={href} - {...rest} - style={[ - {color: t.palette.primary_500}, - (hovered || focused || pressed) && { - outline: 0, - textDecorationLine: 'underline', - textDecorationColor: flattenedStyle.color ?? t.palette.primary_500, - }, - flattenedStyle, - ]} - role="link" - onMouseEnter={onHoverIn} - onMouseLeave={onHoverOut} - accessibilityRole="link" - href={href} - {...web({ - hrefAttrs: { - target: download ? undefined : isExternal ? 'blank' : undefined, - rel: isExternal ? 'noopener noreferrer' : undefined, - download, - }, - dataSet: { - // default to no underline, apply this ourselves - noUnderline: '1', - }, - })}> - {children} - </Text> - </TouchableWithoutFeedback> + onBlur={onBlur} + onMouseEnter={onHoverIn} + onMouseLeave={onHoverOut} + accessibilityRole="link" + href={href} + {...web({ + hrefAttrs: { + target: download ? undefined : isExternal ? 'blank' : undefined, + rel: isExternal ? 'noopener noreferrer' : undefined, + download, + }, + dataSet: { + // default to no underline, apply this ourselves + noUnderline: '1', + }, + })}> + {children} + </Text> ) } diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx index 2c79d27cf..411679102 100644 --- a/src/components/Prompt.tsx +++ b/src/components/Prompt.tsx @@ -41,7 +41,7 @@ export function Outer({ <Dialog.Inner accessibilityLabelledBy={titleId} accessibilityDescribedBy={descriptionId} - style={{width: 'auto', maxWidth: 400}}> + style={[{width: 'auto', maxWidth: 400}]}> {children} </Dialog.Inner> </Context.Provider> diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx index 068ee99e0..c72fcabdd 100644 --- a/src/components/RichText.tsx +++ b/src/components/RichText.tsx @@ -1,9 +1,9 @@ import React from 'react' import {RichText as RichTextAPI, AppBskyRichtextFacet} from '@atproto/api' -import {atoms as a, TextStyleProp} from '#/alf' +import {atoms as a, TextStyleProp, flatten} from '#/alf' import {InlineLink} from '#/components/Link' -import {Text} from '#/components/Typography' +import {Text, TextProps} from '#/components/Typography' import {toShortUrl} from 'lib/strings/url-helpers' import {getAgent} from '#/state/session' @@ -16,18 +16,20 @@ export function RichText({ numberOfLines, disableLinks, resolveFacets = false, -}: TextStyleProp & { - value: RichTextAPI | string - testID?: string - numberOfLines?: number - disableLinks?: boolean - resolveFacets?: boolean -}) { + selectable, +}: TextStyleProp & + Pick<TextProps, 'selectable'> & { + value: RichTextAPI | string + testID?: string + numberOfLines?: number + disableLinks?: boolean + resolveFacets?: boolean + }) { const detected = React.useRef(false) const [richText, setRichText] = React.useState<RichTextAPI>(() => value instanceof RichTextAPI ? value : new RichTextAPI({text: value}), ) - const styles = [a.leading_normal, style] + const styles = [a.leading_snug, flatten(style)] React.useEffect(() => { if (!resolveFacets) return @@ -50,6 +52,7 @@ export function RichText({ if (text.length <= 5 && /^\p{Extended_Pictographic}+$/u.test(text)) { return ( <Text + selectable={selectable} testID={testID} style={[ { @@ -65,6 +68,7 @@ export function RichText({ } return ( <Text + selectable={selectable} testID={testID} style={styles} numberOfLines={numberOfLines} @@ -88,6 +92,7 @@ export function RichText({ ) { els.push( <InlineLink + selectable={selectable} key={key} to={`/profile/${mention.did}`} style={[...styles, {pointerEvents: 'auto'}]} @@ -102,6 +107,7 @@ export function RichText({ } else { els.push( <InlineLink + selectable={selectable} key={key} to={link.uri} style={[...styles, {pointerEvents: 'auto'}]} @@ -120,6 +126,7 @@ export function RichText({ return ( <Text + selectable={selectable} testID={testID} style={styles} numberOfLines={numberOfLines} diff --git a/src/components/Typography.tsx b/src/components/Typography.tsx index b34f51018..c9ab7a8a1 100644 --- a/src/components/Typography.tsx +++ b/src/components/Typography.tsx @@ -1,7 +1,16 @@ import React from 'react' -import {Text as RNText, TextStyle, TextProps} from 'react-native' +import {Text as RNText, TextStyle, TextProps as RNTextProps} from 'react-native' +import {UITextView} from 'react-native-ui-text-view' import {useTheme, atoms, web, flatten} from '#/alf' +import {isIOS} from '#/platform/detection' + +export type TextProps = RNTextProps & { + /** + * Lets the user select text, to use the native copy and paste functionality. + */ + selectable?: boolean +} /** * Util to calculate lineHeight from a text size atom and a leading atom @@ -44,27 +53,24 @@ function normalizeTextStyles(styles: TextStyle[]) { /** * Our main text component. Use this most of the time. */ -export function Text({style, ...rest}: TextProps) { +export function Text({style, selectable, ...rest}: TextProps) { const t = useTheme() const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, flatten(style)]) - return <RNText style={s} {...rest} /> + return selectable && isIOS ? ( + <UITextView style={s} {...rest} /> + ) : ( + <RNText selectable={selectable} style={s} {...rest} /> + ) } export function createHeadingElement({level}: {level: number}) { return function HeadingElement({style, ...rest}: TextProps) { - const t = useTheme() const attr = web({ role: 'heading', 'aria-level': level, }) || {} - return ( - <RNText - {...attr} - {...rest} - style={normalizeTextStyles([t.atoms.text, flatten(style)])} - /> - ) + return <Text {...attr} {...rest} style={style} /> } } @@ -78,21 +84,15 @@ export const H4 = createHeadingElement({level: 4}) export const H5 = createHeadingElement({level: 5}) export const H6 = createHeadingElement({level: 6}) export function P({style, ...rest}: TextProps) { - const t = useTheme() const attr = web({ role: 'paragraph', }) || {} return ( - <RNText + <Text {...attr} {...rest} - style={normalizeTextStyles([ - atoms.text_md, - atoms.leading_normal, - t.atoms.text, - flatten(style), - ])} + style={[atoms.text_md, atoms.leading_normal, flatten(style)]} /> ) } diff --git a/src/components/icons/Times.tsx b/src/components/icons/Times.tsx new file mode 100644 index 000000000..678ac3fcb --- /dev/null +++ b/src/components/icons/Times.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const TimesLarge_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M4.293 4.293a1 1 0 0 1 1.414 0L12 10.586l6.293-6.293a1 1 0 1 1 1.414 1.414L13.414 12l6.293 6.293a1 1 0 0 1-1.414 1.414L12 13.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L10.586 12 4.293 5.707a1 1 0 0 1 0-1.414Z', +}) |