forked from zulip/zulip-mobile
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
StreamSettingsScreen: s/Private/Privacy/; use radio buttons, not a sw…
…itch For zulip#5250, we'll need to let the user choose one among more than two options. That'll call for a radio-button-style input, rather than a switch. So, make a new component InputRowRadioButtons, and use it for an input labeled "Privacy", replacing the SwitchRow input labeled "Private". And, to support that component, write and wire up another new component, SelectableOptionsScreen.
- Loading branch information
1 parent
b224279
commit 5ceec85
Showing
8 changed files
with
296 additions
and
19 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,160 @@ | ||
/* @flow strict-local */ | ||
import React, { useCallback, useRef, useMemo, useEffect } from 'react'; | ||
import type { Node } from 'react'; | ||
import { View } from 'react-native'; | ||
import invariant from 'invariant'; | ||
import { CommonActions } from '@react-navigation/native'; | ||
|
||
import type { LocalizableText } from '../types'; | ||
import { randString } from '../utils/misc'; | ||
import { BRAND_COLOR, createStyleSheet } from '../styles'; | ||
import Touchable from './Touchable'; | ||
import ZulipTextIntl from './ZulipTextIntl'; | ||
import { IconRight } from './Icons'; | ||
import type { AppNavigationProp } from '../nav/AppNavigator'; | ||
|
||
type Item<TKey> = $ReadOnly<{| | ||
key: TKey, | ||
title: LocalizableText, | ||
subtitle?: LocalizableText, | ||
|}>; | ||
|
||
type Props<TItemKey> = $ReadOnly<{| | ||
/** | ||
* Component must be under the stack nav with the selectable-options screen. | ||
* | ||
* Pass this down from props or `useNavigation`. | ||
*/ | ||
navigation: AppNavigationProp<>, | ||
|
||
/** What the setting is about, e.g., "Theme". */ | ||
label: LocalizableText, | ||
|
||
/** What the setting is about, in a short sentence or two. */ | ||
description?: LocalizableText, | ||
|
||
/** The current value of the input. */ | ||
valueKey: TItemKey, | ||
|
||
/** | ||
* The available options to choose from, with unique `key`s, one of which | ||
* must be `valueKey`. | ||
*/ | ||
items: $ReadOnlyArray<Item<TItemKey>>, | ||
|
||
onValueChange: (newValue: TItemKey) => void, | ||
|}>; | ||
|
||
/** | ||
* A form-input row for the user to make a choice, radio-button style. | ||
* | ||
* Shows the current value (the selected item), represented as the item's | ||
* `.title`. When tapped, opens the selectable-options screen, where the | ||
* user can change the selection or read more about each selection. | ||
*/ | ||
// This has the navigate-to-nested-screen semantics of NestedNavRow, | ||
// represented by IconRight. NestedNavRow would probably be the wrong | ||
// abstraction, though, because it isn't an imput component; it doesn't have | ||
// a value to display. | ||
export default function InputRowRadioButtons<TItemKey: string>(props: Props<TItemKey>): Node { | ||
const { navigation, label, description, valueKey, items, onValueChange } = props; | ||
|
||
const screenKey = useRef(`selectable-options-${randString()}`).current; | ||
|
||
const selectedItem = items.find(c => c.key === valueKey); | ||
invariant(selectedItem != null, 'InputRowRadioButtons: exactly one choice must be selected'); | ||
|
||
const screenParams = useMemo( | ||
() => ({ | ||
title: label, | ||
description, | ||
items: items.map(({ key, title, subtitle }) => ({ | ||
key, | ||
title, | ||
subtitle, | ||
selected: key === valueKey, | ||
})), | ||
onRequestSelectionChange: (itemKey, requestedValue) => { | ||
if (!requestedValue || itemKey === valueKey) { | ||
// Can't unselect a radio button. | ||
return; | ||
} | ||
navigation.goBack(); | ||
onValueChange(itemKey); | ||
}, | ||
}), | ||
[navigation, label, description, items, valueKey, onValueChange], | ||
); | ||
|
||
const handleRowPressed = useCallback(() => { | ||
// Normally we'd use `.push`, to avoid `.navigate`'s funky | ||
// rewind-history behavior. But `.push` doesn't accept a custom key, so | ||
// we use `.navigate`. This is fine because the funky rewind-history | ||
// behavior can't happen here; see | ||
// https://github.com/zulip/zulip-mobile/pull/5369#discussion_r868799934 | ||
navigation.navigate({ | ||
name: 'selectable-options', | ||
key: screenKey, | ||
params: screenParams, | ||
}); | ||
}, [navigation, screenKey, screenParams]); | ||
|
||
// Live-update the selectable-options screen. | ||
useEffect(() => { | ||
navigation.dispatch(state => | ||
/* eslint-disable operator-linebreak */ | ||
state.routes.find(route => route.key === screenKey) | ||
? { ...CommonActions.setParams(screenParams), source: screenKey } | ||
: // A screen with key `screenKey` isn't currently mounted on the | ||
// navigator. Don't refer to such a screen; that would be an | ||
// error. | ||
CommonActions.reset(state), | ||
); | ||
}, [navigation, screenParams, screenKey]); | ||
|
||
// The desired height for IconRight, which we'll pass for its `size` prop. | ||
// It'll also be its width. | ||
const kRightArrowIconSize = 24; | ||
|
||
const styles = useMemo( | ||
() => | ||
createStyleSheet({ | ||
wrapper: { | ||
flexDirection: 'row', | ||
alignItems: 'center', | ||
paddingVertical: 8, | ||
paddingHorizontal: 16, | ||
}, | ||
textWrapper: { | ||
flex: 1, | ||
flexDirection: 'column', | ||
}, | ||
valueTitle: { | ||
fontWeight: '300', | ||
fontSize: 13, | ||
}, | ||
iconRightWrapper: { | ||
height: kRightArrowIconSize, | ||
|
||
// IconRight is a square, so width equals height and this is the | ||
// right amount of width to reserve. | ||
width: kRightArrowIconSize, | ||
}, | ||
}), | ||
[], | ||
); | ||
|
||
return ( | ||
<Touchable onPress={handleRowPressed}> | ||
<View style={styles.wrapper}> | ||
<View style={styles.textWrapper}> | ||
<ZulipTextIntl text={label} /> | ||
<ZulipTextIntl text={selectedItem.title} style={styles.valueTitle} /> | ||
</View> | ||
<View style={styles.iconRightWrapper}> | ||
<IconRight size={kRightArrowIconSize} color={BRAND_COLOR} /> | ||
</View> | ||
</View> | ||
</Touchable> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,102 @@ | ||
/* @flow strict-local */ | ||
|
||
import React, { useMemo } from 'react'; | ||
import type { Node } from 'react'; | ||
import { LogBox, View } from 'react-native'; | ||
|
||
import type { LocalizableText } from '../types'; | ||
import type { RouteProp } from '../react-navigation'; | ||
import type { AppNavigationProp } from '../nav/AppNavigator'; | ||
import Screen from './Screen'; | ||
import SelectableOptionRow from './SelectableOptionRow'; | ||
import ZulipTextIntl from './ZulipTextIntl'; | ||
import { createStyleSheet } from '../styles'; | ||
|
||
type Item<TKey> = $ReadOnly<{| | ||
key: TKey, | ||
title: LocalizableText, | ||
subtitle?: LocalizableText, | ||
selected: boolean, | ||
|}>; | ||
|
||
type Props<TItemKey> = $ReadOnly<{| | ||
navigation: AppNavigationProp<'selectable-options'>, | ||
route: RouteProp< | ||
'selectable-options', | ||
{| | ||
title: LocalizableText, | ||
|
||
/** | ||
* An optional explanation, to be shown before the items. | ||
*/ | ||
description?: LocalizableText, | ||
|
||
items: $ReadOnlyArray<Item<TItemKey>>, | ||
|
||
// This param is a function, so React Nav is right to point out that | ||
// it isn't serializable. But this is fine as long as we don't try to | ||
// persist navigation state for this screen or set up deep linking to | ||
// it, hence the LogBox suppression below. | ||
// | ||
// React Navigation doesn't offer a more sensible way to have us pass | ||
// the selection to the calling screen. …We could store the selection | ||
// as a route param on the calling screen, or in Redux. But from this | ||
// screen's perspective, that's basically just setting a global | ||
// variable. Better to offer this explicit, side-effect-free way for | ||
// the data to flow where it should, when it should. | ||
onRequestSelectionChange: (itemKey: TItemKey, requestedValue: boolean) => void, | ||
|}, | ||
>, | ||
|}>; | ||
|
||
// React Navigation would give us a console warning about non-serializable | ||
// route params. For more about the warning, see | ||
// https://reactnavigation.org/docs/5.x/troubleshooting/#i-get-the-warning-non-serializable-values-were-found-in-the-navigation-state | ||
// See comment on this param, above. | ||
LogBox.ignoreLogs([/selectable-options > params\.onRequestSelectionChange \(Function\)/]); | ||
|
||
/** | ||
* A screen for selecting items in a list, checkbox or radio-button style. | ||
* | ||
* The items and selection state are controlled by the route params. Callers | ||
* should declare a unique key for their own use of this route, as follows, | ||
* so that instances can't step on each other: | ||
* | ||
* navigation.push({ name: 'selectable-options', key: 'foo', params: { … } }) | ||
* navigation.dispatch({ ...CommonActions.setParams({ … }), source: 'foo' }) | ||
*/ | ||
// If we need separate components dedicated to checkboxes and radio buttons, | ||
// we can split this. Currently it's up to the caller to enforce the | ||
// radio-button invariant (exactly one item selected) if they want to. | ||
export default function SelectableOptionsScreen<TItemKey: string>(props: Props<TItemKey>): Node { | ||
const { route } = props; | ||
const { title, description, items, onRequestSelectionChange } = route.params; | ||
|
||
const styles = useMemo( | ||
() => | ||
createStyleSheet({ | ||
descriptionWrapper: { padding: 16 }, | ||
}), | ||
[], | ||
); | ||
|
||
return ( | ||
<Screen title={title} scrollEnabled> | ||
{description != null && ( | ||
<View style={styles.descriptionWrapper}> | ||
<ZulipTextIntl text={description} /> | ||
</View> | ||
)} | ||
{items.map(item => ( | ||
<SelectableOptionRow | ||
key={item.key} | ||
itemKey={item.key} | ||
title={item.title} | ||
subtitle={item.subtitle} | ||
selected={item.selected} | ||
onRequestSelectionChange={onRequestSelectionChange} | ||
/> | ||
))} | ||
</Screen> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.