Language select final tweaks (#8914)

* [APP-1303] Redesign/refactor post language select (#8884)

* Nightly source-language update

* Nightly source-language update

* [APP-1303] Redesign/refactor post language select

* update: stylesheets.create to use the latest structure

* update styles to modern structure

* update: dialog breakpoints on web and delete depricated language modals

* remove unused post languages settings dialog

* restructure Post languages dialog

* place the Dialog.Close inside the Dialog.ScrollableInner

* add: language search

* update search and language variables for clarity

* fix: memoize language state lists

* chore: add comments

* update proper colors to the background

* add back older error boundary

* add: tweaks to the mobile and web responsiveness

* add tweaks to center the container

* update labels

* update button and border

* added translation updates

* Update: text input to reuse search input

* remove unused file

* update: web breakpoints

* run eslint and prettier

---------

Co-authored-by: Elijah Seed-Arita <elijaharita@gmail.com>
Co-authored-by: Anastasiya Uraleva <anastasiya@Anastasiyas-MacBook-Pro.local>
Co-authored-by: Anastasiya Uraleva <anastasiya@Mac.localdomain>

* rm old file

* sort out styles, add FlatListFooter component

* rm cancel button in favor of search input X

* get dialog height working on iOS

* delete `DropdownButton`

* hide scroll indicators on android

* ios scroll indicator insets

* get footer sorta working on android

* change button color on press

* rm empty file

---------

Co-authored-by: Anastasiya Uraleva <anastasiyauraleva@gmail.com>
Co-authored-by: Elijah Seed-Arita <elijaharita@gmail.com>
Co-authored-by: Anastasiya Uraleva <anastasiya@Anastasiyas-MacBook-Pro.local>
Co-authored-by: Anastasiya Uraleva <anastasiya@Mac.localdomain>
This commit is contained in:
Samuel Newman
2025-08-27 19:00:36 +03:00
committed by GitHub
parent eac0290143
commit 39d460db51
14 changed files with 489 additions and 747 deletions
+2 -2
View File
@@ -110,7 +110,7 @@ import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn'
import {Gallery} from '#/view/com/composer/photos/Gallery'
import {OpenCameraBtn} from '#/view/com/composer/photos/OpenCameraBtn'
import {SelectGifBtn} from '#/view/com/composer/photos/SelectGifBtn'
import {SelectLangBtn} from '#/view/com/composer/select-language/SelectLangBtn'
import {SelectPostLanguagesBtn} from '#/view/com/composer/select-language/SelectPostLanguagesDialog'
import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage'
// TODO: Prevent naming components that coincide with RN primitives
// due to linting false positives
@@ -1453,7 +1453,7 @@ function ComposerFooter({
/>
</Button>
)}
<SelectLangBtn />
<SelectPostLanguagesBtn />
<CharProgress
count={post.shortenedGraphemeLength}
style={{width: 65}}
@@ -1,133 +0,0 @@
import {useCallback, useMemo} from 'react'
import {Keyboard, StyleSheet} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {LANG_DROPDOWN_HITSLOP} from '#/lib/constants'
import {usePalette} from '#/lib/hooks/usePalette'
import {isNative} from '#/platform/detection'
import {useModalControls} from '#/state/modals'
import {
hasPostLanguage,
toPostLanguages,
useLanguagePrefs,
useLanguagePrefsApi,
} from '#/state/preferences/languages'
import {
DropdownButton,
DropdownItem,
DropdownItemButton,
} from '#/view/com/util/forms/DropdownButton'
import {Text} from '#/view/com/util/text/Text'
import {codeToLanguageName} from '../../../../locale/helpers'
export function SelectLangBtn() {
const pal = usePalette('default')
const {_} = useLingui()
const {openModal} = useModalControls()
const langPrefs = useLanguagePrefs()
const setLangPrefs = useLanguagePrefsApi()
const onPressMore = useCallback(async () => {
if (isNative) {
if (Keyboard.isVisible()) {
Keyboard.dismiss()
}
}
openModal({name: 'post-languages-settings'})
}, [openModal])
const postLanguagesPref = toPostLanguages(langPrefs.postLanguage)
const items: DropdownItem[] = useMemo(() => {
let arr: DropdownItemButton[] = []
function add(commaSeparatedLangCodes: string) {
const langCodes = commaSeparatedLangCodes.split(',')
const langName = langCodes
.map(code => codeToLanguageName(code, langPrefs.appLanguage))
.join(' + ')
/*
* Filter out any duplicates
*/
if (arr.find((item: DropdownItemButton) => item.label === langName)) {
return
}
arr.push({
icon:
langCodes.every(code =>
hasPostLanguage(langPrefs.postLanguage, code),
) && langCodes.length === postLanguagesPref.length
? ['fas', 'circle-dot']
: ['far', 'circle'],
label: langName,
onPress() {
setLangPrefs.setPostLanguage(commaSeparatedLangCodes)
},
})
}
if (postLanguagesPref.length) {
/*
* Re-join here after sanitization bc postLanguageHistory is an array of
* comma-separated strings too
*/
add(langPrefs.postLanguage)
}
// comma-separted strings of lang codes that have been used in the past
for (const lang of langPrefs.postLanguageHistory) {
add(lang)
}
return [
{heading: true, label: _(msg`Post language`)},
...arr.slice(0, 6),
{sep: true},
{
label: _(msg`Other...`),
onPress: onPressMore,
},
]
}, [onPressMore, langPrefs, setLangPrefs, postLanguagesPref, _])
return (
<DropdownButton
type="bare"
testID="selectLangBtn"
items={items}
openUpwards
style={styles.button}
hitSlop={LANG_DROPDOWN_HITSLOP}
accessibilityLabel={_(msg`Language selection`)}
accessibilityHint="">
{postLanguagesPref.length > 0 ? (
<Text type="lg-bold" style={[pal.link, styles.label]} numberOfLines={1}>
{postLanguagesPref
.map(lang => codeToLanguageName(lang, langPrefs.appLanguage))
.join(', ')}
</Text>
) : (
<FontAwesomeIcon
icon="language"
style={pal.link as FontAwesomeIconStyle}
size={26}
/>
)}
</DropdownButton>
)
}
const styles = StyleSheet.create({
button: {
marginHorizontal: 15,
},
label: {
maxWidth: 100,
},
})
@@ -0,0 +1,382 @@
import {useCallback, useMemo, useState} from 'react'
import {Keyboard, useWindowDimensions, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {LANG_DROPDOWN_HITSLOP} from '#/lib/constants'
import {languageName} from '#/locale/helpers'
import {codeToLanguageName} from '#/locale/helpers'
import {type Language, LANGUAGES, LANGUAGES_MAP_CODE2} from '#/locale/languages'
import {isNative, isWeb} from '#/platform/detection'
import {
toPostLanguages,
useLanguagePrefs,
useLanguagePrefsApi,
} from '#/state/preferences/languages'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {SearchInput} from '#/components/forms/SearchInput'
import * as Toggle from '#/components/forms/Toggle'
import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Text} from '#/components/Typography'
export function SelectPostLanguagesBtn() {
const {_} = useLingui()
const langPrefs = useLanguagePrefs()
const t = useTheme()
const control = Dialog.useDialogControl()
const onPressMore = useCallback(async () => {
if (isNative) {
if (Keyboard.isVisible()) {
Keyboard.dismiss()
}
}
control.open()
}, [control])
const postLanguagesPref = toPostLanguages(langPrefs.postLanguage)
return (
<>
<Button
testID="selectLangBtn"
onPress={onPressMore}
size="small"
hitSlop={LANG_DROPDOWN_HITSLOP}
label={_(
msg({
message: `Post language selection`,
comment: `Accessibility label for button that opens dialog to choose post language settings`,
}),
)}
accessibilityHint={_(msg`Opens post language settings`)}
style={[a.mx_md]}>
{({pressed, hovered, focused}) => {
const color =
pressed || hovered || focused
? t.palette.primary_300
: t.palette.primary_500
if (postLanguagesPref.length > 0) {
return (
<Text
style={[
{color},
a.font_bold,
a.text_sm,
a.leading_snug,
{maxWidth: 100},
]}
numberOfLines={1}>
{postLanguagesPref
.map(lang => codeToLanguageName(lang, langPrefs.appLanguage))
.join(', ')}
</Text>
)
} else {
return <GlobeIcon size="xs" style={{color}} />
}
}}
</Button>
<LanguageDialog control={control} />
</>
)
}
function LanguageDialog({control}: {control: Dialog.DialogControlProps}) {
const {height} = useWindowDimensions()
const insets = useSafeAreaInsets()
const renderErrorBoundary = useCallback(
(error: any) => <DialogError details={String(error)} />,
[],
)
return (
<Dialog.Outer
control={control}
nativeOptions={{minHeight: height - insets.top}}>
<Dialog.Handle />
<ErrorBoundary renderError={renderErrorBoundary}>
<PostLanguagesSettingsDialogInner />
</ErrorBoundary>
</Dialog.Outer>
)
}
export function PostLanguagesSettingsDialogInner() {
const control = Dialog.useDialogContext()
const [headerHeight, setHeaderHeight] = useState(0)
const allowedLanguages = useMemo(() => {
const uniqueLanguagesMap = LANGUAGES.filter(lang => !!lang.code2).reduce(
(acc, lang) => {
acc[lang.code2] = lang
return acc
},
{} as Record<string, Language>,
)
return Object.values(uniqueLanguagesMap)
}, [])
const langPrefs = useLanguagePrefs()
const [checkedLanguagesCode2, setCheckedLanguagesCode2] = useState<string[]>(
langPrefs.postLanguage.split(',') || [langPrefs.primaryLanguage],
)
const [search, setSearch] = useState('')
const setLangPrefs = useLanguagePrefsApi()
const t = useTheme()
const {_} = useLingui()
const handleClose = () => {
control.close(() => {
let langsString = checkedLanguagesCode2.join(',')
if (!langsString) {
langsString = langPrefs.primaryLanguage
}
setLangPrefs.setPostLanguage(langsString)
})
}
// NOTE(@elijaharita): Displayed languages are split into 3 lists for
// ordering.
const displayedLanguages = useMemo(() => {
function mapCode2List(code2List: string[]) {
return code2List.map(code2 => LANGUAGES_MAP_CODE2[code2]).filter(Boolean)
}
// NOTE(@elijaharita): Get recent language codes and map them to language
// objects. Both the user account's saved language history and the current
// checked languages are displayed here.
const recentLanguagesCode2 =
Array.from(
new Set([...checkedLanguagesCode2, ...langPrefs.postLanguageHistory]),
).slice(0, 5) || []
const recentLanguages = mapCode2List(recentLanguagesCode2)
// NOTE(@elijaharita): helper functions
const matchesSearch = (lang: Language) =>
lang.name.toLowerCase().includes(search.toLowerCase())
const isChecked = (lang: Language) =>
checkedLanguagesCode2.includes(lang.code2)
const isInRecents = (lang: Language) =>
recentLanguagesCode2.includes(lang.code2)
const checkedRecent = recentLanguages.filter(isChecked)
if (search) {
// NOTE(@elijaharita): if a search is active, we ALWAYS show checked
// items, as well as any items that match the search.
const uncheckedRecent = recentLanguages
.filter(lang => !isChecked(lang))
.filter(matchesSearch)
const unchecked = allowedLanguages.filter(lang => !isChecked(lang))
const all = unchecked
.filter(matchesSearch)
.filter(lang => !isInRecents(lang))
return {
all,
checkedRecent,
uncheckedRecent,
}
} else {
// NOTE(@elijaharita): if no search is active, we show everything.
const uncheckedRecent = recentLanguages.filter(lang => !isChecked(lang))
const all = allowedLanguages
.filter(lang => !recentLanguagesCode2.includes(lang.code2))
.filter(lang => !isInRecents(lang))
return {
all,
checkedRecent,
uncheckedRecent,
}
}
}, [
allowedLanguages,
search,
langPrefs.postLanguageHistory,
checkedLanguagesCode2,
])
const listHeader = (
<View
style={[a.pb_xs, t.atoms.bg, isNative && a.pt_2xl]}
onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}>
<View style={[a.flex_row, a.w_full, a.justify_between]}>
<View>
<Text
nativeID="dialog-title"
style={[
t.atoms.text,
a.text_left,
a.font_bold,
a.text_xl,
a.mb_sm,
]}>
<Trans>Choose Post Languages</Trans>
</Text>
<Text
nativeID="dialog-description"
style={[
t.atoms.text_contrast_medium,
a.text_left,
a.text_md,
a.mb_lg,
]}>
<Trans>Select up to 3 languages used in this post</Trans>
</Text>
</View>
{isWeb && (
<Button
variant="ghost"
size="small"
color="secondary"
shape="round"
label={_(msg`Close dialog`)}
onPress={handleClose}>
<ButtonIcon icon={XIcon} />
</Button>
)}
</View>
<View style={[a.w_full, a.flex_row, a.align_stretch, a.gap_xs, a.pb_0]}>
<SearchInput
value={search}
onChangeText={setSearch}
placeholder={_(msg`Search languages`)}
label={_(msg`Search languages`)}
maxLength={50}
onClearText={() => setSearch('')}
/>
</View>
</View>
)
const isCheckedRecentEmpty =
displayedLanguages.checkedRecent.length > 0 ||
displayedLanguages.uncheckedRecent.length > 0
const isDisplayedLanguagesEmpty = displayedLanguages.all.length === 0
const flatListData = [
...(isCheckedRecentEmpty
? [{type: 'header', label: _(msg`Recently used`)}]
: []),
...displayedLanguages.checkedRecent.map(lang => ({type: 'item', lang})),
...displayedLanguages.uncheckedRecent.map(lang => ({type: 'item', lang})),
...(isDisplayedLanguagesEmpty
? []
: [{type: 'header', label: _(msg`All languages`)}]),
...displayedLanguages.all.map(lang => ({type: 'item', lang})),
]
return (
<Toggle.Group
values={checkedLanguagesCode2}
onChange={setCheckedLanguagesCode2}
type="checkbox"
maxSelections={3}
label={_(msg`Select languages`)}
style={web([a.contents])}>
<Dialog.InnerFlatList
data={flatListData}
ListHeaderComponent={listHeader}
stickyHeaderIndices={[0]}
contentContainerStyle={[a.gap_0]}
style={[isNative && a.px_lg, web({paddingBottom: 120})]}
scrollIndicatorInsets={{top: headerHeight}}
renderItem={({item, index}) => {
if (item.type === 'header') {
return (
<Text
key={index}
style={[
a.px_0,
a.py_md,
a.font_bold,
a.text_xs,
t.atoms.text_contrast_low,
a.pt_3xl,
]}>
{item.label}
</Text>
)
}
const lang = item.lang
return (
<Toggle.Item
key={lang.code2}
name={lang.code2}
label={languageName(lang, langPrefs.appLanguage)}
style={[
t.atoms.border_contrast_low,
a.border_b,
a.rounded_0,
a.px_0,
a.py_md,
]}>
<Toggle.LabelText style={[a.flex_1]}>
{languageName(lang, langPrefs.appLanguage)}
</Toggle.LabelText>
<Toggle.Checkbox />
</Toggle.Item>
)
}}
footer={
<Dialog.FlatListFooter>
<Button
label={_(msg`Close dialog`)}
onPress={handleClose}
color="primary"
size="large">
<ButtonText>
<Trans>Done</Trans>
</ButtonText>
</Button>
</Dialog.FlatListFooter>
}
/>
</Toggle.Group>
)
}
function DialogError({details}: {details?: string}) {
const {_} = useLingui()
const control = Dialog.useDialogContext()
return (
<Dialog.ScrollableInner
style={a.gap_md}
label={_(msg`An error has occurred`)}>
<Dialog.Close />
<ErrorScreen
title={_(msg`Oh no!`)}
message={_(
msg`There was an unexpected issue in the application. Please let us know if this happened to you!`,
)}
details={details}
/>
<Button
label={_(msg`Close dialog`)}
onPress={() => control.close()}
color="primary"
size="large">
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
</Dialog.ScrollableInner>
)
}
-4
View File
@@ -11,7 +11,6 @@ import * as CreateOrEditListModal from './CreateOrEditList'
import * as DeleteAccountModal from './DeleteAccount'
import * as InviteCodesModal from './InviteCodes'
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
import * as UserAddRemoveListsModal from './UserAddRemoveLists'
const DEFAULT_SNAPPOINTS = ['90%']
@@ -60,9 +59,6 @@ export function ModalsContainer() {
} else if (activeModal?.name === 'content-languages-settings') {
snapPoints = ContentLanguagesSettingsModal.snapPoints
element = <ContentLanguagesSettingsModal.Component />
} else if (activeModal?.name === 'post-languages-settings') {
snapPoints = PostLanguagesSettingsModal.snapPoints
element = <PostLanguagesSettingsModal.Component />
} else {
return null
}
-3
View File
@@ -10,7 +10,6 @@ import * as CreateOrEditListModal from './CreateOrEditList'
import * as DeleteAccountModal from './DeleteAccount'
import * as InviteCodesModal from './InviteCodes'
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
import * as UserAddRemoveLists from './UserAddRemoveLists'
export function ModalsContainer() {
@@ -59,8 +58,6 @@ function Modal({modal}: {modal: ModalIface}) {
element = <InviteCodesModal.Component />
} else if (modal.name === 'content-languages-settings') {
element = <ContentLanguagesSettingsModal.Component />
} else if (modal.name === 'post-languages-settings') {
element = <PostLanguagesSettingsModal.Component />
} else {
return null
}
@@ -1,145 +0,0 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {Trans} from '@lingui/macro'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {deviceLanguageCodes} from '#/locale/deviceLocales'
import {languageName} from '#/locale/helpers'
import {useModalControls} from '#/state/modals'
import {
hasPostLanguage,
useLanguagePrefs,
useLanguagePrefsApi,
} from '#/state/preferences/languages'
import {ToggleButton} from '#/view/com/util/forms/ToggleButton'
import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../../locale/languages'
import {Text} from '../../util/text/Text'
import {ScrollView} from '../util'
import {ConfirmLanguagesButton} from './ConfirmLanguagesButton'
export const snapPoints = ['100%']
export function Component() {
const {closeModal} = useModalControls()
const langPrefs = useLanguagePrefs()
const setLangPrefs = useLanguagePrefsApi()
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
const onPressDone = React.useCallback(() => {
closeModal()
}, [closeModal])
const languages = React.useMemo(() => {
const langs = LANGUAGES.filter(
lang =>
!!lang.code2.trim() &&
LANGUAGES_MAP_CODE2[lang.code2].code3 === lang.code3,
)
// sort so that device & selected languages are on top, then alphabetically
langs.sort((a, b) => {
const hasA =
hasPostLanguage(langPrefs.postLanguage, a.code2) ||
deviceLanguageCodes.includes(a.code2)
const hasB =
hasPostLanguage(langPrefs.postLanguage, b.code2) ||
deviceLanguageCodes.includes(b.code2)
if (hasA === hasB) return a.name.localeCompare(b.name)
if (hasA) return -1
return 1
})
return langs
}, [langPrefs])
const onPress = React.useCallback(
(code2: string) => {
setLangPrefs.togglePostLanguage(code2)
},
[setLangPrefs],
)
return (
<View
testID="postLanguagesModal"
style={[
pal.view,
styles.container,
// @ts-ignore vh is on web only
isMobile
? {
paddingTop: 20,
}
: {
maxHeight: '90vh',
},
]}>
<Text style={[pal.text, styles.title]}>
<Trans>Post Languages</Trans>
</Text>
<Text style={[pal.text, styles.description]}>
<Trans>Which languages are used in this post?</Trans>
</Text>
<ScrollView style={styles.scrollContainer}>
{languages.map(lang => {
const isSelected = hasPostLanguage(langPrefs.postLanguage, lang.code2)
// enforce a max of 3 selections for post languages
let isDisabled = false
if (langPrefs.postLanguage.split(',').length >= 3 && !isSelected) {
isDisabled = true
}
return (
<ToggleButton
key={lang.code2}
label={languageName(lang, langPrefs.appLanguage)}
isSelected={isSelected}
onPress={() => (isDisabled ? undefined : onPress(lang.code2))}
style={[
pal.border,
styles.languageToggle,
isDisabled && styles.dimmed,
]}
/>
)
})}
<View
style={{
height: isMobile ? 60 : 0,
}}
/>
</ScrollView>
<ConfirmLanguagesButton onPress={onPressDone} />
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
title: {
textAlign: 'center',
fontWeight: '600',
fontSize: 24,
marginBottom: 12,
},
description: {
textAlign: 'center',
paddingHorizontal: 16,
marginBottom: 10,
},
scrollContainer: {
flex: 1,
paddingHorizontal: 10,
},
languageToggle: {
borderTopWidth: 1,
borderRadius: 0,
paddingHorizontal: 6,
paddingVertical: 12,
},
dimmed: {
opacity: 0.5,
},
})
-397
View File
@@ -1,397 +0,0 @@
import {type PropsWithChildren} from 'react'
import {useMemo, useRef} from 'react'
import {
Dimensions,
type GestureResponderEvent,
type Insets,
type StyleProp,
StyleSheet,
TouchableOpacity,
TouchableWithoutFeedback,
useWindowDimensions,
View,
type ViewStyle,
} from 'react-native'
import Animated, {FadeIn, FadeInDown, FadeInUp} from 'react-native-reanimated'
import RootSiblings from 'react-native-root-siblings'
import {type IconProp} from '@fortawesome/fontawesome-svg-core'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import type React from 'react'
import {HITSLOP_10} from '#/lib/constants'
import {usePalette} from '#/lib/hooks/usePalette'
import {colors} from '#/lib/styles'
import {useTheme} from '#/lib/ThemeContext'
import {isWeb} from '#/platform/detection'
import {native} from '#/alf'
import {FullWindowOverlay} from '#/components/FullWindowOverlay'
import {Text} from '../text/Text'
import {Button, type ButtonType} from './Button'
const ESTIMATED_BTN_HEIGHT = 50
const ESTIMATED_SEP_HEIGHT = 16
const ESTIMATED_HEADING_HEIGHT = 60
export interface DropdownItemButton {
testID?: string
icon?: IconProp
label: string
onPress: () => void
}
export interface DropdownItemSeparator {
sep: true
}
export interface DropdownItemHeading {
heading: true
label: string
}
export type DropdownItem =
| DropdownItemButton
| DropdownItemSeparator
| DropdownItemHeading
type MaybeDropdownItem = DropdownItem | false | undefined
export type DropdownButtonType = ButtonType | 'bare'
interface DropdownButtonProps {
testID?: string
type?: DropdownButtonType
style?: StyleProp<ViewStyle>
items: MaybeDropdownItem[]
label?: string
menuWidth?: number
children?: React.ReactNode
openToRight?: boolean
openUpwards?: boolean
rightOffset?: number
bottomOffset?: number
hitSlop?: Insets
accessibilityLabel?: string
accessibilityHint?: string
}
/**
* @deprecated use Menu from `#/components/Menu.tsx` instead
*/
export function DropdownButton({
testID,
type = 'bare',
style,
items,
label,
menuWidth,
children,
openToRight = false,
openUpwards = false,
rightOffset = 0,
bottomOffset = 0,
hitSlop = HITSLOP_10,
accessibilityLabel,
}: PropsWithChildren<DropdownButtonProps>) {
const {_} = useLingui()
const ref1 = useRef<View>(null)
const ref2 = useRef<View>(null)
const onPress = (e: GestureResponderEvent) => {
const ref = ref1.current || ref2.current
const {height: winHeight} = Dimensions.get('window')
const pressY = e.nativeEvent.pageY
ref?.measure(
(
_x: number,
_y: number,
width: number,
_height: number,
pageX: number,
pageY: number,
) => {
if (!menuWidth) {
menuWidth = 200
}
let estimatedMenuHeight = 0
for (const item of items) {
if (item && isSep(item)) {
estimatedMenuHeight += ESTIMATED_SEP_HEIGHT
} else if (item && isBtn(item)) {
estimatedMenuHeight += ESTIMATED_BTN_HEIGHT
} else if (item && isHeading(item)) {
estimatedMenuHeight += ESTIMATED_HEADING_HEIGHT
}
}
const newX = openToRight
? pageX + width + rightOffset
: pageX + width - menuWidth
// Add a bit of additional room
let newY = pressY + bottomOffset + 20
if (openUpwards || newY + estimatedMenuHeight > winHeight) {
newY -= estimatedMenuHeight
}
createDropdownMenu(
newX,
newY,
pageY,
menuWidth,
items.filter(v => !!v) as DropdownItem[],
openUpwards,
)
},
)
}
const numItems = useMemo(
() =>
items.filter(item => {
if (item === undefined || item === false) {
return false
}
return isBtn(item)
}).length,
[items],
)
if (type === 'bare') {
return (
<TouchableOpacity
testID={testID}
style={style}
onPress={onPress}
hitSlop={hitSlop}
ref={ref1}
accessibilityRole="button"
accessibilityLabel={
accessibilityLabel || _(msg`Opens ${numItems} options`)
}
accessibilityHint="">
{children}
</TouchableOpacity>
)
}
return (
<View ref={ref2}>
<Button
type={type}
testID={testID}
onPress={onPress}
style={style}
label={label}>
{children}
</Button>
</View>
)
}
function createDropdownMenu(
x: number,
y: number,
pageY: number,
width: number,
items: DropdownItem[],
opensUpwards = false,
): RootSiblings {
const onPressItem = (index: number) => {
sibling.destroy()
const item = items[index]
if (isBtn(item)) {
item.onPress()
}
}
const onOuterPress = () => sibling.destroy()
const sibling = new RootSiblings(
(
<DropdownItems
onOuterPress={onOuterPress}
x={x}
y={y}
pageY={pageY}
width={width}
items={items}
onPressItem={onPressItem}
openUpwards={opensUpwards}
/>
),
)
return sibling
}
type DropDownItemProps = {
onOuterPress: () => void
x: number
y: number
pageY: number
width: number
items: DropdownItem[]
onPressItem: (index: number) => void
openUpwards: boolean
}
const DropdownItems = ({
onOuterPress,
x,
y,
pageY,
width,
items,
onPressItem,
openUpwards,
}: DropDownItemProps) => {
const pal = usePalette('default')
const theme = useTheme()
const {_} = useLingui()
const {height: screenHeight} = useWindowDimensions()
const dropDownBackgroundColor =
theme.colorScheme === 'dark' ? pal.btn : pal.view
const separatorColor =
theme.colorScheme === 'dark' ? pal.borderDark : pal.border
const numItems = items.filter(isBtn).length
// TODO: Refactor dropdown components to:
// - (On web, if not handled by React Native) use semantic <select />
// and <option /> elements for keyboard navigation out of the box
// - (On mobile) be buttons by default, accept `label` and `nativeID`
// props, and always have an explicit label
return (
<FullWindowOverlay>
{/* This TouchableWithoutFeedback renders the background so if the user clicks outside, the dropdown closes */}
<TouchableWithoutFeedback
onPress={onOuterPress}
accessibilityLabel={_(msg`Toggle dropdown`)}
accessibilityHint="">
<Animated.View
entering={FadeIn}
style={[
styles.bg,
// On web we need to adjust the top and bottom relative to the scroll position
isWeb
? {
top: -pageY,
bottom: pageY - screenHeight,
}
: {
top: 0,
bottom: 0,
},
]}
/>
</TouchableWithoutFeedback>
<Animated.View
entering={native(
openUpwards ? FadeInDown.springify(1000) : FadeInUp.springify(1000),
)}
style={[
styles.menu,
{left: x, top: y, width},
dropDownBackgroundColor,
]}>
{items.map((item, index) => {
if (isBtn(item)) {
return (
<TouchableOpacity
testID={item.testID}
key={index}
style={[styles.menuItem]}
onPress={() => onPressItem(index)}
accessibilityRole="button"
accessibilityLabel={item.label}
accessibilityHint={_(
msg`Selects option ${index + 1} of ${numItems}`,
)}>
{item.icon && (
<FontAwesomeIcon
style={styles.icon}
icon={item.icon}
color={pal.text.color as string}
/>
)}
<Text style={[styles.label, pal.text]}>{item.label}</Text>
</TouchableOpacity>
)
} else if (isSep(item)) {
return (
<View key={index} style={[styles.separator, separatorColor]} />
)
} else if (isHeading(item)) {
return (
<View style={[styles.heading, pal.border]} key={index}>
<Text style={[pal.text, styles.headingLabel]}>
{item.label}
</Text>
</View>
)
}
return null
})}
</Animated.View>
</FullWindowOverlay>
)
}
function isSep(item: DropdownItem): item is DropdownItemSeparator {
return 'sep' in item && item.sep
}
function isHeading(item: DropdownItem): item is DropdownItemHeading {
return 'heading' in item && item.heading
}
function isBtn(item: DropdownItem): item is DropdownItemButton {
return !isSep(item) && !isHeading(item)
}
const styles = StyleSheet.create({
bg: {
position: 'absolute',
left: 0,
width: '100%',
backgroundColor: 'rgba(0, 0, 0, 0.1)',
},
menu: {
position: 'absolute',
backgroundColor: '#fff',
borderRadius: 14,
paddingVertical: 6,
},
menuItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
paddingLeft: 15,
paddingRight: 40,
},
menuItemBorder: {
borderTopWidth: 1,
borderTopColor: colors.gray1,
marginTop: 4,
paddingTop: 12,
},
icon: {
marginLeft: 2,
marginRight: 8,
flexShrink: 0,
},
label: {
fontSize: 18,
flexShrink: 1,
flexGrow: 1,
},
separator: {
borderTopWidth: 1,
marginVertical: 8,
},
heading: {
flexDirection: 'row',
justifyContent: 'center',
paddingVertical: 10,
paddingLeft: 15,
paddingRight: 20,
borderBottomWidth: 1,
marginBottom: 6,
},
headingLabel: {
fontSize: 18,
fontWeight: '600',
},
})
+5 -46
View File
@@ -4,17 +4,16 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {usePalette} from '#/lib/hooks/usePalette'
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
import {
type CommonNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {s} from '#/lib/styles'
import {PaletteColorName, ThemeProvider} from '#/lib/ThemeContext'
import {type PaletteColorName, ThemeProvider} from '#/lib/ThemeContext'
import {EmptyState} from '#/view/com/util/EmptyState'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {Button} from '#/view/com/util/forms/Button'
import {
DropdownButton,
DropdownItem,
} from '#/view/com/util/forms/DropdownButton'
import {ToggleButton} from '#/view/com/util/forms/ToggleButton'
import * as LoadingPlaceholder from '#/view/com/util/LoadingPlaceholder'
import {Text} from '#/view/com/util/text/Text'
@@ -134,8 +133,6 @@ function ControlsView() {
<ScrollView style={[s.pl10, s.pr10]}>
<Heading label="Buttons" />
<ButtonsView />
<Heading label="Dropdown Buttons" />
<DropdownButtonsView />
<Heading label="Toggle Buttons" />
<ToggleButtonsView />
<View style={s.footerSpacer} />
@@ -396,44 +393,6 @@ function ButtonsView() {
)
}
const DROPDOWN_ITEMS: DropdownItem[] = [
{
icon: ['far', 'paste'],
label: 'Copy post text',
onPress() {},
},
{
icon: 'share',
label: 'Share...',
onPress() {},
},
{
icon: 'circle-exclamation',
label: 'Report post',
onPress() {},
},
]
function DropdownButtonsView() {
const defaultPal = usePalette('default')
return (
<View style={[defaultPal.view]}>
<View style={s.mb5}>
<DropdownButton
type="primary"
items={DROPDOWN_ITEMS}
menuWidth={200}
label="Primary button"
/>
</View>
<View style={s.mb5}>
<DropdownButton type="bare" items={DROPDOWN_ITEMS} menuWidth={200}>
<Text>Bare</Text>
</DropdownButton>
</View>
</View>
)
}
function ToggleButtonsView() {
const defaultPal = usePalette('default')
const buttonStyles = s.mb5