✨ SegmentedControl component (#8606)
* new segmented control * fix type error * convert server input, use CSS for web * add segmented control to storybook * use segmented control in embed dialog * add to suggested text wrappers * update change handle dialog * update styles since button changes * fix atom * style updates to segmented control, add size prop * update state in layout effect rather than in render * set type = 'radio' as default * prevent expansion in server dialog on iOS * use non reactive callback in needsUpdate effect
This commit is contained in:
@@ -43,6 +43,7 @@ module.exports = {
|
|||||||
suggestedTextWrappers: {
|
suggestedTextWrappers: {
|
||||||
Button: 'ButtonText',
|
Button: 'ButtonText',
|
||||||
'ToggleButton.Button': 'ToggleButton.ButtonText',
|
'ToggleButton.Button': 'ToggleButton.ButtonText',
|
||||||
|
'SegmentedControl.Item': 'SegmentedControl.ItemText',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import {toShareUrl} from '#/lib/strings/url-helpers'
|
|||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import * as SegmentedControl from '#/components/forms/SegmentedControl'
|
||||||
import * as TextField from '#/components/forms/TextField'
|
import * as TextField from '#/components/forms/TextField'
|
||||||
import * as ToggleButton from '#/components/forms/ToggleButton'
|
|
||||||
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
|
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
|
||||||
import {
|
import {
|
||||||
ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottomIcon,
|
ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottomIcon,
|
||||||
@@ -150,26 +150,27 @@ function EmbedDialogInner({
|
|||||||
<Text style={[t.atoms.text_contrast_medium, a.font_semi_bold]}>
|
<Text style={[t.atoms.text_contrast_medium, a.font_semi_bold]}>
|
||||||
<Trans>Color theme</Trans>
|
<Trans>Color theme</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<ToggleButton.Group
|
<SegmentedControl.Root
|
||||||
label={_(msg`Color mode`)}
|
label={_(msg`Color mode`)}
|
||||||
values={[colorMode]}
|
type="radio"
|
||||||
onChange={([value]) => setColorMode(value as ColorModeValues)}>
|
value={colorMode}
|
||||||
<ToggleButton.Button name="system" label={_(msg`System`)}>
|
onChange={setColorMode}>
|
||||||
<ToggleButton.ButtonText>
|
<SegmentedControl.Item value="system" label={_(msg`System`)}>
|
||||||
|
<SegmentedControl.ItemText>
|
||||||
<Trans>System</Trans>
|
<Trans>System</Trans>
|
||||||
</ToggleButton.ButtonText>
|
</SegmentedControl.ItemText>
|
||||||
</ToggleButton.Button>
|
</SegmentedControl.Item>
|
||||||
<ToggleButton.Button name="light" label={_(msg`Light`)}>
|
<SegmentedControl.Item value="light" label={_(msg`Light`)}>
|
||||||
<ToggleButton.ButtonText>
|
<SegmentedControl.ItemText>
|
||||||
<Trans>Light</Trans>
|
<Trans>Light</Trans>
|
||||||
</ToggleButton.ButtonText>
|
</SegmentedControl.ItemText>
|
||||||
</ToggleButton.Button>
|
</SegmentedControl.Item>
|
||||||
<ToggleButton.Button name="dark" label={_(msg`Dark`)}>
|
<SegmentedControl.Item value="dark" label={_(msg`Dark`)}>
|
||||||
<ToggleButton.ButtonText>
|
<SegmentedControl.ItemText>
|
||||||
<Trans>Dark</Trans>
|
<Trans>Dark</Trans>
|
||||||
</ToggleButton.ButtonText>
|
</SegmentedControl.ItemText>
|
||||||
</ToggleButton.Button>
|
</SegmentedControl.Item>
|
||||||
</ToggleButton.Group>
|
</SegmentedControl.Root>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -5,18 +5,20 @@ import {msg, Trans} from '@lingui/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {BSKY_SERVICE} from '#/lib/constants'
|
import {BSKY_SERVICE} from '#/lib/constants'
|
||||||
import {logEvent} from '#/lib/statsig/statsig'
|
import {logger} from '#/logger'
|
||||||
import * as persisted from '#/state/persisted'
|
import * as persisted from '#/state/persisted'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
import {atoms as a, platform, useBreakpoints, useTheme, web} from '#/alf'
|
||||||
import {Admonition} from '#/components/Admonition'
|
import {Admonition} from '#/components/Admonition'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import * as SegmentedControl from '#/components/forms/SegmentedControl'
|
||||||
import * as TextField from '#/components/forms/TextField'
|
import * as TextField from '#/components/forms/TextField'
|
||||||
import * as ToggleButton from '#/components/forms/ToggleButton'
|
|
||||||
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
|
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
|
||||||
import {InlineLinkText} from '#/components/Link'
|
import {InlineLinkText} from '#/components/Link'
|
||||||
import {P, Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
type SegmentedControlOptions = typeof BSKY_SERVICE | 'custom'
|
||||||
|
|
||||||
export function ServerInputDialog({
|
export function ServerInputDialog({
|
||||||
control,
|
control,
|
||||||
@@ -29,7 +31,8 @@ export function ServerInputDialog({
|
|||||||
const formRef = useRef<DialogInnerRef>(null)
|
const formRef = useRef<DialogInnerRef>(null)
|
||||||
|
|
||||||
// persist these options between dialog open/close
|
// persist these options between dialog open/close
|
||||||
const [fixedOption, setFixedOption] = useState(BSKY_SERVICE)
|
const [fixedOption, setFixedOption] =
|
||||||
|
useState<SegmentedControlOptions>(BSKY_SERVICE)
|
||||||
const [previousCustomAddress, setPreviousCustomAddress] = useState('')
|
const [previousCustomAddress, setPreviousCustomAddress] = useState('')
|
||||||
|
|
||||||
const onClose = useCallback(() => {
|
const onClose = useCallback(() => {
|
||||||
@@ -40,7 +43,7 @@ export function ServerInputDialog({
|
|||||||
setPreviousCustomAddress(result)
|
setPreviousCustomAddress(result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logEvent('signin:hostingProviderPressed', {
|
logger.metric('signin:hostingProviderPressed', {
|
||||||
hostingProviderDidChange: fixedOption !== BSKY_SERVICE,
|
hostingProviderDidChange: fixedOption !== BSKY_SERVICE,
|
||||||
})
|
})
|
||||||
}, [onSelect, fixedOption])
|
}, [onSelect, fixedOption])
|
||||||
@@ -49,7 +52,10 @@ export function ServerInputDialog({
|
|||||||
<Dialog.Outer
|
<Dialog.Outer
|
||||||
control={control}
|
control={control}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
nativeOptions={{minHeight: height / 2}}>
|
nativeOptions={platform({
|
||||||
|
android: {minHeight: height / 2},
|
||||||
|
ios: {preventExpansion: true},
|
||||||
|
})}>
|
||||||
<Dialog.Handle />
|
<Dialog.Handle />
|
||||||
<DialogInner
|
<DialogInner
|
||||||
formRef={formRef}
|
formRef={formRef}
|
||||||
@@ -70,8 +76,8 @@ function DialogInner({
|
|||||||
initialCustomAddress,
|
initialCustomAddress,
|
||||||
}: {
|
}: {
|
||||||
formRef: React.Ref<DialogInnerRef>
|
formRef: React.Ref<DialogInnerRef>
|
||||||
fixedOption: string
|
fixedOption: SegmentedControlOptions
|
||||||
setFixedOption: (opt: string) => void
|
setFixedOption: (opt: SegmentedControlOptions) => void
|
||||||
initialCustomAddress: string
|
initialCustomAddress: string
|
||||||
}) {
|
}) {
|
||||||
const control = Dialog.useDialogContext()
|
const control = Dialog.useDialogContext()
|
||||||
@@ -124,45 +130,49 @@ function DialogInner({
|
|||||||
return (
|
return (
|
||||||
<Dialog.ScrollableInner
|
<Dialog.ScrollableInner
|
||||||
accessibilityDescribedBy="dialog-description"
|
accessibilityDescribedBy="dialog-description"
|
||||||
accessibilityLabelledBy="dialog-title">
|
accessibilityLabelledBy="dialog-title"
|
||||||
|
style={web({maxWidth: 500})}>
|
||||||
<View style={[a.relative, a.gap_md, a.w_full]}>
|
<View style={[a.relative, a.gap_md, a.w_full]}>
|
||||||
<Text nativeID="dialog-title" style={[a.text_2xl, a.font_semi_bold]}>
|
<Text nativeID="dialog-title" style={[a.text_2xl, a.font_bold]}>
|
||||||
<Trans>Choose your account provider</Trans>
|
<Trans>Choose your account provider</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<ToggleButton.Group
|
<SegmentedControl.Root
|
||||||
label="Preferences"
|
type="tabs"
|
||||||
values={[fixedOption]}
|
label={_(msg`Account provider`)}
|
||||||
onChange={values => setFixedOption(values[0])}>
|
value={fixedOption}
|
||||||
<ToggleButton.Button name={BSKY_SERVICE} label={_(msg`Bluesky`)}>
|
onChange={setFixedOption}>
|
||||||
<ToggleButton.ButtonText>{_(msg`Bluesky`)}</ToggleButton.ButtonText>
|
<SegmentedControl.Item
|
||||||
</ToggleButton.Button>
|
testID="bskyServiceSelectBtn"
|
||||||
<ToggleButton.Button
|
value={BSKY_SERVICE}
|
||||||
|
label={_(msg`Bluesky`)}>
|
||||||
|
<SegmentedControl.ItemText>
|
||||||
|
{_(msg`Bluesky`)}
|
||||||
|
</SegmentedControl.ItemText>
|
||||||
|
</SegmentedControl.Item>
|
||||||
|
<SegmentedControl.Item
|
||||||
testID="customSelectBtn"
|
testID="customSelectBtn"
|
||||||
name="custom"
|
value="custom"
|
||||||
label={_(msg`Custom`)}>
|
label={_(msg`Custom`)}>
|
||||||
<ToggleButton.ButtonText>{_(msg`Custom`)}</ToggleButton.ButtonText>
|
<SegmentedControl.ItemText>
|
||||||
</ToggleButton.Button>
|
{_(msg`Custom`)}
|
||||||
</ToggleButton.Group>
|
</SegmentedControl.ItemText>
|
||||||
|
</SegmentedControl.Item>
|
||||||
|
</SegmentedControl.Root>
|
||||||
|
|
||||||
{fixedOption === BSKY_SERVICE && isFirstTimeUser && (
|
{fixedOption === BSKY_SERVICE && isFirstTimeUser && (
|
||||||
|
<View role="tabpanel">
|
||||||
<Admonition type="tip">
|
<Admonition type="tip">
|
||||||
<Trans>
|
<Trans>
|
||||||
Bluesky is an open network where you can choose your own provider.
|
Bluesky is an open network where you can choose your own
|
||||||
If you're new here, we recommend sticking with the default Bluesky
|
provider. If you're new here, we recommend sticking with the
|
||||||
Social option.
|
default Bluesky Social option.
|
||||||
</Trans>
|
</Trans>
|
||||||
</Admonition>
|
</Admonition>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{fixedOption === 'custom' && (
|
{fixedOption === 'custom' && (
|
||||||
<View
|
<View role="tabpanel">
|
||||||
style={[
|
|
||||||
a.border,
|
|
||||||
t.atoms.border_contrast_low,
|
|
||||||
a.rounded_sm,
|
|
||||||
a.px_md,
|
|
||||||
a.py_md,
|
|
||||||
]}>
|
|
||||||
<TextField.LabelText nativeID="address-input-label">
|
<TextField.LabelText nativeID="address-input-label">
|
||||||
<Trans>Server address</Trans>
|
<Trans>Server address</Trans>
|
||||||
</TextField.LabelText>
|
</TextField.LabelText>
|
||||||
@@ -197,13 +207,8 @@ function DialogInner({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={[a.py_xs]}>
|
<View style={[a.py_xs]}>
|
||||||
<P
|
<Text
|
||||||
style={[
|
style={[t.atoms.text_contrast_medium, a.text_sm, a.leading_snug]}>
|
||||||
t.atoms.text_contrast_medium,
|
|
||||||
a.text_sm,
|
|
||||||
a.leading_snug,
|
|
||||||
a.flex_1,
|
|
||||||
]}>
|
|
||||||
{isFirstTimeUser ? (
|
{isFirstTimeUser ? (
|
||||||
<Trans>
|
<Trans>
|
||||||
If you're a developer, you can host your own server.
|
If you're a developer, you can host your own server.
|
||||||
@@ -219,18 +224,23 @@ function DialogInner({
|
|||||||
to="https://atproto.com/guides/self-hosting">
|
to="https://atproto.com/guides/self-hosting">
|
||||||
<Trans>Learn more.</Trans>
|
<Trans>Learn more.</Trans>
|
||||||
</InlineLinkText>
|
</InlineLinkText>
|
||||||
</P>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={gtMobile && [a.flex_row, a.justify_end]}>
|
<View style={gtMobile && [a.flex_row, a.justify_end]}>
|
||||||
<Button
|
<Button
|
||||||
testID="doneBtn"
|
testID="doneBtn"
|
||||||
variant="outline"
|
variant="solid"
|
||||||
color="primary"
|
color="primary"
|
||||||
size="small"
|
size={platform({
|
||||||
|
native: 'large',
|
||||||
|
web: 'small',
|
||||||
|
})}
|
||||||
onPress={() => control.close()}
|
onPress={() => control.close()}
|
||||||
label={_(msg`Done`)}>
|
label={_(msg`Done`)}>
|
||||||
<ButtonText>{_(msg`Done`)}</ButtonText>
|
<ButtonText>
|
||||||
|
<Trans>Done</Trans>
|
||||||
|
</ButtonText>
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -4,10 +4,10 @@ import {msg, Trans} from '@lingui/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {toNiceDomain} from '#/lib/strings/url-helpers'
|
import {toNiceDomain} from '#/lib/strings/url-helpers'
|
||||||
import {ServerInputDialog} from '#/view/com/auth/server-input'
|
|
||||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import {useDialogControl} from '#/components/Dialog'
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
|
import {ServerInputDialog} from '#/components/dialogs/ServerInput'
|
||||||
import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe'
|
import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe'
|
||||||
import {PencilLine_Stroke2_Corner0_Rounded as PencilIcon} from '#/components/icons/Pencil'
|
import {PencilLine_Stroke2_Corner0_Rounded as PencilIcon} from '#/components/icons/Pencil'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||||
|
import Animated, {Easing, LinearTransition} from 'react-native-reanimated'
|
||||||
|
|
||||||
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
|
import {atoms as a, native, platform, useTheme} from '#/alf'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
type ButtonProps,
|
||||||
|
ButtonText,
|
||||||
|
type ButtonTextProps,
|
||||||
|
} from '../Button'
|
||||||
|
|
||||||
|
const InternalContext = createContext<{
|
||||||
|
type: 'tabs' | 'radio'
|
||||||
|
size: 'small' | 'large'
|
||||||
|
selectedValue: string
|
||||||
|
selectedPosition: {width: number; x: number} | null
|
||||||
|
onSelectValue: (
|
||||||
|
value: string,
|
||||||
|
position: {width: number; x: number} | null,
|
||||||
|
) => void
|
||||||
|
updatePosition: (position: {width: number; x: number}) => void
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Segmented control component.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <SegmentedControl.Root value={value} onChange={setValue}>
|
||||||
|
* <SegmentedControl.Item value="one">
|
||||||
|
* <SegmentedControl.ItemText value="one">
|
||||||
|
* One
|
||||||
|
* </SegmentedControl.ItemText>
|
||||||
|
* </SegmentedControl.Item>
|
||||||
|
* <SegmentedControl.Item value="two">
|
||||||
|
* <SegmentedControl.ItemText value="two">
|
||||||
|
* Two
|
||||||
|
* </SegmentedControl.ItemText>
|
||||||
|
* </SegmentedControl.Item>
|
||||||
|
* </SegmentedControl.Root>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function Root<T extends string>({
|
||||||
|
label,
|
||||||
|
type = 'radio',
|
||||||
|
size = 'large',
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
accessibilityHint,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
type: 'tabs' | 'radio'
|
||||||
|
size?: 'small' | 'large'
|
||||||
|
value: T
|
||||||
|
onChange: (value: T) => void
|
||||||
|
children: React.ReactNode
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
accessibilityHint?: string
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const [selectedPosition, setSelectedPosition] = useState<{
|
||||||
|
width: number
|
||||||
|
x: number
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const contextValue = useMemo(() => {
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
size,
|
||||||
|
selectedValue: value,
|
||||||
|
selectedPosition,
|
||||||
|
onSelectValue: (
|
||||||
|
val: string,
|
||||||
|
position: {width: number; x: number} | null,
|
||||||
|
) => {
|
||||||
|
onChange(val as T)
|
||||||
|
if (position) setSelectedPosition(position)
|
||||||
|
},
|
||||||
|
updatePosition: (position: {width: number; x: number}) => {
|
||||||
|
setSelectedPosition(currPos => {
|
||||||
|
if (
|
||||||
|
currPos &&
|
||||||
|
currPos.width === position.width &&
|
||||||
|
currPos.x === position.x
|
||||||
|
) {
|
||||||
|
return currPos
|
||||||
|
}
|
||||||
|
return position
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}, [value, selectedPosition, setSelectedPosition, onChange, type, size])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
accessibilityLabel={label}
|
||||||
|
accessibilityHint={accessibilityHint ?? ''}
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.flex_1,
|
||||||
|
a.relative,
|
||||||
|
a.flex_row,
|
||||||
|
t.atoms.bg_contrast_50,
|
||||||
|
{borderRadius: 14},
|
||||||
|
a.curve_continuous,
|
||||||
|
a.p_xs,
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
role={type === 'tabs' ? 'tablist' : 'radiogroup'}>
|
||||||
|
{selectedPosition !== null && (
|
||||||
|
<Slider x={selectedPosition.x} width={selectedPosition.width} />
|
||||||
|
)}
|
||||||
|
<InternalContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</InternalContext.Provider>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const InternalItemContext = createContext<{
|
||||||
|
active: boolean
|
||||||
|
pressed: boolean
|
||||||
|
hovered: boolean
|
||||||
|
focused: boolean
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
export function Item({
|
||||||
|
value,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
onPress: onPressProp,
|
||||||
|
...props
|
||||||
|
}: {value: string; children: React.ReactNode} & Omit<ButtonProps, 'children'>) {
|
||||||
|
const [position, setPosition] = useState<{x: number; width: number} | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const ctx = useContext(InternalContext)
|
||||||
|
if (!ctx)
|
||||||
|
throw new Error(
|
||||||
|
'SegmentedControl.Item must be used within a SegmentedControl.Root',
|
||||||
|
)
|
||||||
|
|
||||||
|
const active = ctx.selectedValue === value
|
||||||
|
|
||||||
|
// update position if change was external, and not due to onPress
|
||||||
|
const needsUpdate =
|
||||||
|
active &&
|
||||||
|
position &&
|
||||||
|
(ctx.selectedPosition?.x !== position.x ||
|
||||||
|
ctx.selectedPosition?.width !== position.width)
|
||||||
|
|
||||||
|
// can't wait for `useEffectEvent`
|
||||||
|
const update = useNonReactiveCallback(() => {
|
||||||
|
if (position) ctx.updatePosition(position)
|
||||||
|
})
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (needsUpdate) {
|
||||||
|
update()
|
||||||
|
}
|
||||||
|
}, [needsUpdate, update])
|
||||||
|
|
||||||
|
const onPress = useCallback(
|
||||||
|
(evt: any) => {
|
||||||
|
ctx.onSelectValue(value, position)
|
||||||
|
onPressProp?.(evt)
|
||||||
|
},
|
||||||
|
[ctx, value, position, onPressProp],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[a.flex_1, a.flex_row]}
|
||||||
|
onLayout={evt => {
|
||||||
|
const measuredPosition = {
|
||||||
|
x: evt.nativeEvent.layout.x,
|
||||||
|
width: evt.nativeEvent.layout.width,
|
||||||
|
}
|
||||||
|
if (!ctx.selectedPosition && active) {
|
||||||
|
ctx.onSelectValue(value, measuredPosition)
|
||||||
|
}
|
||||||
|
setPosition(measuredPosition)
|
||||||
|
}}>
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
onPress={onPress}
|
||||||
|
role={ctx.type === 'tabs' ? 'tab' : 'radio'}
|
||||||
|
accessibilityState={{selected: active}}
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.bg_transparent,
|
||||||
|
a.px_sm,
|
||||||
|
a.py_xs,
|
||||||
|
{minHeight: ctx.size === 'large' ? 40 : 32},
|
||||||
|
style,
|
||||||
|
]}>
|
||||||
|
{({pressed, hovered, focused}) => (
|
||||||
|
<InternalItemContext.Provider
|
||||||
|
value={{active, pressed, hovered, focused}}>
|
||||||
|
{children}
|
||||||
|
</InternalItemContext.Provider>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ItemText({style, ...props}: ButtonTextProps) {
|
||||||
|
const t = useTheme()
|
||||||
|
const ctx = useContext(InternalItemContext)
|
||||||
|
if (!ctx)
|
||||||
|
throw new Error(
|
||||||
|
'SegmentedControl.ItemText must be used within a SegmentedControl.Item',
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<ButtonText
|
||||||
|
{...props}
|
||||||
|
style={[
|
||||||
|
a.text_center,
|
||||||
|
a.text_md,
|
||||||
|
a.font_medium,
|
||||||
|
a.px_xs,
|
||||||
|
ctx.active
|
||||||
|
? t.atoms.text
|
||||||
|
: ctx.focused || ctx.hovered || ctx.pressed
|
||||||
|
? t.atoms.text_contrast_medium
|
||||||
|
: t.atoms.text_contrast_low,
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Slider({x, width}: {x: number; width: number}) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
layout={native(LinearTransition.easing(Easing.out(Easing.exp)))}
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.curve_continuous,
|
||||||
|
t.atoms.bg,
|
||||||
|
{
|
||||||
|
top: 4,
|
||||||
|
bottom: 4,
|
||||||
|
left: 0,
|
||||||
|
width,
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
// TODO: new arch supports boxShadow on native
|
||||||
|
// in the meantime this is an attempt to get close
|
||||||
|
platform({
|
||||||
|
web: {
|
||||||
|
boxShadow: '0px 2px 4px 0px #0000000D',
|
||||||
|
},
|
||||||
|
ios: {
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {width: 0, height: 2},
|
||||||
|
shadowOpacity: 0x0d / 0xff,
|
||||||
|
shadowRadius: 4,
|
||||||
|
},
|
||||||
|
android: {elevation: 0.25},
|
||||||
|
}),
|
||||||
|
platform({
|
||||||
|
native: [{left: x}],
|
||||||
|
web: [{transform: [{translateX: x}]}, a.transition_transform],
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react'
|
import {useMemo} from 'react'
|
||||||
import {
|
import {
|
||||||
type AccessibilityProps,
|
type AccessibilityProps,
|
||||||
type TextStyle,
|
type TextStyle,
|
||||||
@@ -20,6 +20,9 @@ export type GroupProps = Omit<Toggle.GroupProps, 'style' | 'type'> & {
|
|||||||
multiple?: boolean
|
multiple?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated - use SegmentedControl
|
||||||
|
*/
|
||||||
export function Group({children, multiple, ...props}: GroupProps) {
|
export function Group({children, multiple, ...props}: GroupProps) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
return (
|
return (
|
||||||
@@ -39,6 +42,9 @@ export function Group({children, multiple, ...props}: GroupProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated - use SegmentedControl
|
||||||
|
*/
|
||||||
export function Button({children, ...props}: ItemProps) {
|
export function Button({children, ...props}: ItemProps) {
|
||||||
return (
|
return (
|
||||||
<Toggle.Item {...props} style={[a.flex_grow, a.flex_1]}>
|
<Toggle.Item {...props} style={[a.flex_grow, a.flex_1]}>
|
||||||
@@ -51,7 +57,7 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) {
|
|||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const state = Toggle.useItemContext()
|
const state = Toggle.useItemContext()
|
||||||
|
|
||||||
const {baseStyles, hoverStyles, activeStyles} = React.useMemo(() => {
|
const {baseStyles, hoverStyles, activeStyles} = useMemo(() => {
|
||||||
const base: ViewStyle[] = []
|
const base: ViewStyle[] = []
|
||||||
const hover: ViewStyle[] = []
|
const hover: ViewStyle[] = []
|
||||||
const active: ViewStyle[] = []
|
const active: ViewStyle[] = []
|
||||||
@@ -112,11 +118,14 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated - use SegmentedControl
|
||||||
|
*/
|
||||||
export function ButtonText({children}: {children: React.ReactNode}) {
|
export function ButtonText({children}: {children: React.ReactNode}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const state = Toggle.useItemContext()
|
const state = Toggle.useItemContext()
|
||||||
|
|
||||||
const textStyles = React.useMemo(() => {
|
const textStyles = useMemo(() => {
|
||||||
const text: TextStyle[] = []
|
const text: TextStyle[] = []
|
||||||
if (state.selected) {
|
if (state.selected) {
|
||||||
text.push(t.atoms.text_inverted)
|
text.push(t.atoms.text_inverted)
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import {
|
|||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
import {useSetThemePrefs, useThemePrefs} from '#/state/shell'
|
import {useSetThemePrefs, useThemePrefs} from '#/state/shell'
|
||||||
import {SettingsListItem as AppIconSettingsListItem} from '#/screens/Settings/AppIconSettings/SettingsListItem'
|
import {SettingsListItem as AppIconSettingsListItem} from '#/screens/Settings/AppIconSettings/SettingsListItem'
|
||||||
import {atoms as a, native, useAlf, useTheme} from '#/alf'
|
import {type Alf, atoms as a, native, useAlf, useTheme} from '#/alf'
|
||||||
import * as ToggleButton from '#/components/forms/ToggleButton'
|
import * as SegmentedControl from '#/components/forms/SegmentedControl'
|
||||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||||
import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon'
|
import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon'
|
||||||
import {Phone_Stroke2_Corner0_Rounded as PhoneIcon} from '#/components/icons/Phone'
|
import {Phone_Stroke2_Corner0_Rounded as PhoneIcon} from '#/components/icons/Phone'
|
||||||
@@ -36,42 +36,29 @@ export function AppearanceSettingsScreen({}: Props) {
|
|||||||
const {setColorMode, setDarkTheme} = useSetThemePrefs()
|
const {setColorMode, setDarkTheme} = useSetThemePrefs()
|
||||||
|
|
||||||
const onChangeAppearance = useCallback(
|
const onChangeAppearance = useCallback(
|
||||||
(keys: string[]) => {
|
(value: 'light' | 'system' | 'dark') => {
|
||||||
const appearance = keys.find(key => key !== colorMode) as
|
setColorMode(value)
|
||||||
| 'system'
|
|
||||||
| 'light'
|
|
||||||
| 'dark'
|
|
||||||
| undefined
|
|
||||||
if (!appearance) return
|
|
||||||
setColorMode(appearance)
|
|
||||||
},
|
},
|
||||||
[setColorMode, colorMode],
|
[setColorMode],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onChangeDarkTheme = useCallback(
|
const onChangeDarkTheme = useCallback(
|
||||||
(keys: string[]) => {
|
(value: 'dim' | 'dark') => {
|
||||||
const theme = keys.find(key => key !== darkTheme) as
|
setDarkTheme(value)
|
||||||
| 'dim'
|
|
||||||
| 'dark'
|
|
||||||
| undefined
|
|
||||||
if (!theme) return
|
|
||||||
setDarkTheme(theme)
|
|
||||||
},
|
},
|
||||||
[setDarkTheme, darkTheme],
|
[setDarkTheme],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onChangeFontFamily = useCallback(
|
const onChangeFontFamily = useCallback(
|
||||||
(values: string[]) => {
|
(value: 'system' | 'theme') => {
|
||||||
const next = values[0] === 'system' ? 'system' : 'theme'
|
fonts.setFontFamily(value)
|
||||||
fonts.setFontFamily(next)
|
|
||||||
},
|
},
|
||||||
[fonts],
|
[fonts],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onChangeFontScale = useCallback(
|
const onChangeFontScale = useCallback(
|
||||||
(values: string[]) => {
|
(value: Alf['fonts']['scale']) => {
|
||||||
const next = values[0] || ('0' as any)
|
fonts.setFontScale(value)
|
||||||
fonts.setFontScale(next)
|
|
||||||
},
|
},
|
||||||
[fonts],
|
[fonts],
|
||||||
)
|
)
|
||||||
@@ -107,7 +94,7 @@ export function AppearanceSettingsScreen({}: Props) {
|
|||||||
name: 'dark',
|
name: 'dark',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
values={[colorMode]}
|
value={colorMode}
|
||||||
onChange={onChangeAppearance}
|
onChange={onChangeAppearance}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -128,7 +115,7 @@ export function AppearanceSettingsScreen({}: Props) {
|
|||||||
name: 'dark',
|
name: 'dark',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
values={[darkTheme ?? 'dim']}
|
value={darkTheme ?? 'dim'}
|
||||||
onChange={onChangeDarkTheme}
|
onChange={onChangeDarkTheme}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -153,7 +140,7 @@ export function AppearanceSettingsScreen({}: Props) {
|
|||||||
name: 'theme',
|
name: 'theme',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
values={[fonts.family]}
|
value={fonts.family}
|
||||||
onChange={onChangeFontFamily}
|
onChange={onChangeFontFamily}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -174,7 +161,7 @@ export function AppearanceSettingsScreen({}: Props) {
|
|||||||
name: '1',
|
name: '1',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
values={[fonts.scale]}
|
value={fonts.scale}
|
||||||
onChange={onChangeFontScale}
|
onChange={onChangeFontScale}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -192,12 +179,12 @@ export function AppearanceSettingsScreen({}: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppearanceToggleButtonGroup({
|
export function AppearanceToggleButtonGroup<T extends string>({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
items,
|
items,
|
||||||
values,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
@@ -205,10 +192,10 @@ export function AppearanceToggleButtonGroup({
|
|||||||
icon: React.ComponentType<SVGIconProps>
|
icon: React.ComponentType<SVGIconProps>
|
||||||
items: {
|
items: {
|
||||||
label: string
|
label: string
|
||||||
name: string
|
name: T
|
||||||
}[]
|
}[]
|
||||||
values: string[]
|
value: T
|
||||||
onChange: (values: string[]) => void
|
onChange: (value: T) => void
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
return (
|
return (
|
||||||
@@ -227,16 +214,22 @@ export function AppearanceToggleButtonGroup({
|
|||||||
{description}
|
{description}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<ToggleButton.Group label={title} values={values} onChange={onChange}>
|
<SegmentedControl.Root
|
||||||
|
type="radio"
|
||||||
|
label={title}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}>
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<ToggleButton.Button
|
<SegmentedControl.Item
|
||||||
key={item.name}
|
key={item.name}
|
||||||
label={item.label}
|
label={item.label}
|
||||||
name={item.name}>
|
value={item.name}>
|
||||||
<ToggleButton.ButtonText>{item.label}</ToggleButton.ButtonText>
|
<SegmentedControl.ItemText>
|
||||||
</ToggleButton.Button>
|
{item.label}
|
||||||
|
</SegmentedControl.ItemText>
|
||||||
|
</SegmentedControl.Item>
|
||||||
))}
|
))}
|
||||||
</ToggleButton.Group>
|
</SegmentedControl.Root>
|
||||||
</SettingsList.Group>
|
</SettingsList.Group>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ import {atoms as a, native, useBreakpoints, useTheme} from '#/alf'
|
|||||||
import {Admonition} from '#/components/Admonition'
|
import {Admonition} from '#/components/Admonition'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import * as SegmentedControl from '#/components/forms/SegmentedControl'
|
||||||
import * as TextField from '#/components/forms/TextField'
|
import * as TextField from '#/components/forms/TextField'
|
||||||
import * as ToggleButton from '#/components/forms/ToggleButton'
|
|
||||||
import {
|
import {
|
||||||
ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon,
|
ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon,
|
||||||
ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon,
|
ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon,
|
||||||
@@ -395,21 +395,22 @@ function OwnHandlePage({goToServiceHandle}: {goToServiceHandle: () => void}) {
|
|||||||
/>
|
/>
|
||||||
</TextField.Root>
|
</TextField.Root>
|
||||||
</View>
|
</View>
|
||||||
<ToggleButton.Group
|
<SegmentedControl.Root
|
||||||
label={_(msg`Choose domain verification method`)}
|
label={_(msg`Choose domain verification method`)}
|
||||||
values={[dnsPanel ? 'dns' : 'file']}
|
type="tabs"
|
||||||
onChange={values => setDNSPanel(values[0] === 'dns')}>
|
value={dnsPanel ? 'dns' : 'file'}
|
||||||
<ToggleButton.Button name="dns" label={_(msg`DNS Panel`)}>
|
onChange={values => setDNSPanel(values === 'dns')}>
|
||||||
<ToggleButton.ButtonText>
|
<SegmentedControl.Item value="dns" label={_(msg`DNS Panel`)}>
|
||||||
|
<SegmentedControl.ItemText>
|
||||||
<Trans>DNS Panel</Trans>
|
<Trans>DNS Panel</Trans>
|
||||||
</ToggleButton.ButtonText>
|
</SegmentedControl.ItemText>
|
||||||
</ToggleButton.Button>
|
</SegmentedControl.Item>
|
||||||
<ToggleButton.Button name="file" label={_(msg`No DNS Panel`)}>
|
<SegmentedControl.Item value="file" label={_(msg`No DNS Panel`)}>
|
||||||
<ToggleButton.ButtonText>
|
<SegmentedControl.ItemText>
|
||||||
<Trans>No DNS Panel</Trans>
|
<Trans>No DNS Panel</Trans>
|
||||||
</ToggleButton.ButtonText>
|
</SegmentedControl.ItemText>
|
||||||
</ToggleButton.Button>
|
</SegmentedControl.Item>
|
||||||
</ToggleButton.Group>
|
</SegmentedControl.Root>
|
||||||
{dnsPanel ? (
|
{dnsPanel ? (
|
||||||
<>
|
<>
|
||||||
<Text>
|
<Text>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {type TextInput, View} from 'react-native'
|
|||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
import {DateField, LabelText} from '#/components/forms/DateField'
|
import {DateField, LabelText} from '#/components/forms/DateField'
|
||||||
|
import * as SegmentedControl from '#/components/forms/SegmentedControl'
|
||||||
import * as TextField from '#/components/forms/TextField'
|
import * as TextField from '#/components/forms/TextField'
|
||||||
import * as Toggle from '#/components/forms/Toggle'
|
import * as Toggle from '#/components/forms/Toggle'
|
||||||
import * as ToggleButton from '#/components/forms/ToggleButton'
|
import * as ToggleButton from '#/components/forms/ToggleButton'
|
||||||
@@ -15,6 +16,9 @@ export function Forms() {
|
|||||||
const [toggleGroupBValues, setToggleGroupBValues] = React.useState(['a', 'b'])
|
const [toggleGroupBValues, setToggleGroupBValues] = React.useState(['a', 'b'])
|
||||||
const [toggleGroupCValues, setToggleGroupCValues] = React.useState(['a', 'b'])
|
const [toggleGroupCValues, setToggleGroupCValues] = React.useState(['a', 'b'])
|
||||||
const [toggleGroupDValues, setToggleGroupDValues] = React.useState(['warn'])
|
const [toggleGroupDValues, setToggleGroupDValues] = React.useState(['warn'])
|
||||||
|
const [segmentedControlValue, setSegmentedControlValue] = React.useState<
|
||||||
|
'hide' | 'warn' | 'show'
|
||||||
|
>('warn')
|
||||||
|
|
||||||
const [value, setValue] = React.useState('')
|
const [value, setValue] = React.useState('')
|
||||||
const [date, setDate] = React.useState('2001-01-01')
|
const [date, setDate] = React.useState('2001-01-01')
|
||||||
@@ -254,23 +258,26 @@ export function Forms() {
|
|||||||
<ToggleButton.ButtonText>Show</ToggleButton.ButtonText>
|
<ToggleButton.ButtonText>Show</ToggleButton.ButtonText>
|
||||||
</ToggleButton.Button>
|
</ToggleButton.Button>
|
||||||
</ToggleButton.Group>
|
</ToggleButton.Group>
|
||||||
|
|
||||||
<View>
|
|
||||||
<ToggleButton.Group
|
|
||||||
label="Preferences"
|
|
||||||
values={toggleGroupDValues}
|
|
||||||
onChange={setToggleGroupDValues}>
|
|
||||||
<ToggleButton.Button name="hide" label="Hide">
|
|
||||||
<ToggleButton.ButtonText>Hide</ToggleButton.ButtonText>
|
|
||||||
</ToggleButton.Button>
|
|
||||||
<ToggleButton.Button name="warn" label="Warn">
|
|
||||||
<ToggleButton.ButtonText>Warn</ToggleButton.ButtonText>
|
|
||||||
</ToggleButton.Button>
|
|
||||||
<ToggleButton.Button name="show" label="Show">
|
|
||||||
<ToggleButton.ButtonText>Show</ToggleButton.ButtonText>
|
|
||||||
</ToggleButton.Button>
|
|
||||||
</ToggleButton.Group>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View style={[a.gap_md, a.align_start, a.w_full]}>
|
||||||
|
<H3>SegmentedControl</H3>
|
||||||
|
|
||||||
|
<SegmentedControl.Root
|
||||||
|
label="Preferences"
|
||||||
|
type="radio"
|
||||||
|
value={segmentedControlValue}
|
||||||
|
onChange={setSegmentedControlValue}>
|
||||||
|
<SegmentedControl.Item value="hide" label="Hide">
|
||||||
|
<SegmentedControl.ItemText>Hide</SegmentedControl.ItemText>
|
||||||
|
</SegmentedControl.Item>
|
||||||
|
<SegmentedControl.Item value="warn" label="Warn">
|
||||||
|
<SegmentedControl.ItemText>Warn</SegmentedControl.ItemText>
|
||||||
|
</SegmentedControl.Item>
|
||||||
|
<SegmentedControl.Item value="show" label="Show">
|
||||||
|
<SegmentedControl.ItemText>Show</SegmentedControl.ItemText>
|
||||||
|
</SegmentedControl.Item>
|
||||||
|
</SegmentedControl.Root>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user