diff --git a/.eslintrc.js b/.eslintrc.js
index 8dab053c24..37ed895aa4 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -43,6 +43,7 @@ module.exports = {
suggestedTextWrappers: {
Button: 'ButtonText',
'ToggleButton.Button': 'ToggleButton.ButtonText',
+ 'SegmentedControl.Item': 'SegmentedControl.ItemText',
},
},
],
diff --git a/src/components/dialogs/Embed.tsx b/src/components/dialogs/Embed.tsx
index a61004fd2e..048cf208ba 100644
--- a/src/components/dialogs/Embed.tsx
+++ b/src/components/dialogs/Embed.tsx
@@ -10,8 +10,8 @@ import {toShareUrl} from '#/lib/strings/url-helpers'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
+import * as SegmentedControl from '#/components/forms/SegmentedControl'
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 {
ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottomIcon,
@@ -150,26 +150,27 @@ function EmbedDialogInner({
Color theme
- setColorMode(value as ColorModeValues)}>
-
-
+ type="radio"
+ value={colorMode}
+ onChange={setColorMode}>
+
+ System
-
-
-
-
+
+
+
+ Light
-
-
-
-
+
+
+
+ Dark
-
-
-
+
+
+
)}
diff --git a/src/view/com/auth/server-input/index.tsx b/src/components/dialogs/ServerInput.tsx
similarity index 73%
rename from src/view/com/auth/server-input/index.tsx
rename to src/components/dialogs/ServerInput.tsx
index c79b8a5794..d7c02bb9f4 100644
--- a/src/view/com/auth/server-input/index.tsx
+++ b/src/components/dialogs/ServerInput.tsx
@@ -5,18 +5,20 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {BSKY_SERVICE} from '#/lib/constants'
-import {logEvent} from '#/lib/statsig/statsig'
+import {logger} from '#/logger'
import * as persisted from '#/state/persisted'
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 {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
+import * as SegmentedControl from '#/components/forms/SegmentedControl'
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 {InlineLinkText} from '#/components/Link'
-import {P, Text} from '#/components/Typography'
+import {Text} from '#/components/Typography'
+
+type SegmentedControlOptions = typeof BSKY_SERVICE | 'custom'
export function ServerInputDialog({
control,
@@ -29,7 +31,8 @@ export function ServerInputDialog({
const formRef = useRef(null)
// persist these options between dialog open/close
- const [fixedOption, setFixedOption] = useState(BSKY_SERVICE)
+ const [fixedOption, setFixedOption] =
+ useState(BSKY_SERVICE)
const [previousCustomAddress, setPreviousCustomAddress] = useState('')
const onClose = useCallback(() => {
@@ -40,7 +43,7 @@ export function ServerInputDialog({
setPreviousCustomAddress(result)
}
}
- logEvent('signin:hostingProviderPressed', {
+ logger.metric('signin:hostingProviderPressed', {
hostingProviderDidChange: fixedOption !== BSKY_SERVICE,
})
}, [onSelect, fixedOption])
@@ -49,7 +52,10 @@ export function ServerInputDialog({
+ nativeOptions={platform({
+ android: {minHeight: height / 2},
+ ios: {preventExpansion: true},
+ })}>
- fixedOption: string
- setFixedOption: (opt: string) => void
+ fixedOption: SegmentedControlOptions
+ setFixedOption: (opt: SegmentedControlOptions) => void
initialCustomAddress: string
}) {
const control = Dialog.useDialogContext()
@@ -124,45 +130,49 @@ function DialogInner({
return (
+ accessibilityLabelledBy="dialog-title"
+ style={web({maxWidth: 500})}>
-
+ Choose your account provider
- setFixedOption(values[0])}>
-
- {_(msg`Bluesky`)}
-
-
+
+
+ {_(msg`Bluesky`)}
+
+
+
- {_(msg`Custom`)}
-
-
+
+ {_(msg`Custom`)}
+
+
+
{fixedOption === BSKY_SERVICE && isFirstTimeUser && (
-
-
- Bluesky is an open network where you can choose your own provider.
- If you're new here, we recommend sticking with the default Bluesky
- Social option.
-
-
+
+
+
+ Bluesky is an open network where you can choose your own
+ provider. If you're new here, we recommend sticking with the
+ default Bluesky Social option.
+
+
+
)}
{fixedOption === 'custom' && (
-
+ Server address
@@ -197,13 +207,8 @@ function DialogInner({
)}
-
+
{isFirstTimeUser ? (
If you're a developer, you can host your own server.
@@ -219,18 +224,23 @@ function DialogInner({
to="https://atproto.com/guides/self-hosting">
Learn more.
-
+
diff --git a/src/components/forms/HostingProvider.tsx b/src/components/forms/HostingProvider.tsx
index 1100900176..b7d23ba3ab 100644
--- a/src/components/forms/HostingProvider.tsx
+++ b/src/components/forms/HostingProvider.tsx
@@ -4,10 +4,10 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {toNiceDomain} from '#/lib/strings/url-helpers'
-import {ServerInputDialog} from '#/view/com/auth/server-input'
import {atoms as a, tokens, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
+import {ServerInputDialog} from '#/components/dialogs/ServerInput'
import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe'
import {PencilLine_Stroke2_Corner0_Rounded as PencilIcon} from '#/components/icons/Pencil'
import {Text} from '#/components/Typography'
diff --git a/src/components/forms/SegmentedControl.tsx b/src/components/forms/SegmentedControl.tsx
new file mode 100644
index 0000000000..71da847e1d
--- /dev/null
+++ b/src/components/forms/SegmentedControl.tsx
@@ -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
+ *
+ *
+ *
+ * One
+ *
+ *
+ *
+ *
+ * Two
+ *
+ *
+ *
+ * ```
+ */
+export function Root({
+ 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
+ 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 (
+
+ {selectedPosition !== null && (
+
+ )}
+
+ {children}
+
+
+ )
+}
+
+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) {
+ 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 (
+ {
+ const measuredPosition = {
+ x: evt.nativeEvent.layout.x,
+ width: evt.nativeEvent.layout.width,
+ }
+ if (!ctx.selectedPosition && active) {
+ ctx.onSelectValue(value, measuredPosition)
+ }
+ setPosition(measuredPosition)
+ }}>
+
+
+ )
+}
+
+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 (
+
+ )
+}
+
+function Slider({x, width}: {x: number; width: number}) {
+ const t = useTheme()
+
+ return (
+
+ )
+}
diff --git a/src/components/forms/ToggleButton.tsx b/src/components/forms/ToggleButton.tsx
index 367122585b..77e12b2046 100644
--- a/src/components/forms/ToggleButton.tsx
+++ b/src/components/forms/ToggleButton.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import {useMemo} from 'react'
import {
type AccessibilityProps,
type TextStyle,
@@ -20,6 +20,9 @@ export type GroupProps = Omit & {
multiple?: boolean
}
+/**
+ * @deprecated - use SegmentedControl
+ */
export function Group({children, multiple, ...props}: GroupProps) {
const t = useTheme()
return (
@@ -39,6 +42,9 @@ export function Group({children, multiple, ...props}: GroupProps) {
)
}
+/**
+ * @deprecated - use SegmentedControl
+ */
export function Button({children, ...props}: ItemProps) {
return (
@@ -51,7 +57,7 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) {
const t = useTheme()
const state = Toggle.useItemContext()
- const {baseStyles, hoverStyles, activeStyles} = React.useMemo(() => {
+ const {baseStyles, hoverStyles, activeStyles} = useMemo(() => {
const base: ViewStyle[] = []
const hover: ViewStyle[] = []
const active: ViewStyle[] = []
@@ -112,11 +118,14 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) {
)
}
+/**
+ * @deprecated - use SegmentedControl
+ */
export function ButtonText({children}: {children: React.ReactNode}) {
const t = useTheme()
const state = Toggle.useItemContext()
- const textStyles = React.useMemo(() => {
+ const textStyles = useMemo(() => {
const text: TextStyle[] = []
if (state.selected) {
text.push(t.atoms.text_inverted)
diff --git a/src/screens/Settings/AppearanceSettings.tsx b/src/screens/Settings/AppearanceSettings.tsx
index 5d597ff8e2..658434c03f 100644
--- a/src/screens/Settings/AppearanceSettings.tsx
+++ b/src/screens/Settings/AppearanceSettings.tsx
@@ -15,8 +15,8 @@ import {
import {isNative} from '#/platform/detection'
import {useSetThemePrefs, useThemePrefs} from '#/state/shell'
import {SettingsListItem as AppIconSettingsListItem} from '#/screens/Settings/AppIconSettings/SettingsListItem'
-import {atoms as a, native, useAlf, useTheme} from '#/alf'
-import * as ToggleButton from '#/components/forms/ToggleButton'
+import {type Alf, atoms as a, native, useAlf, useTheme} from '#/alf'
+import * as SegmentedControl from '#/components/forms/SegmentedControl'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon'
import {Phone_Stroke2_Corner0_Rounded as PhoneIcon} from '#/components/icons/Phone'
@@ -36,42 +36,29 @@ export function AppearanceSettingsScreen({}: Props) {
const {setColorMode, setDarkTheme} = useSetThemePrefs()
const onChangeAppearance = useCallback(
- (keys: string[]) => {
- const appearance = keys.find(key => key !== colorMode) as
- | 'system'
- | 'light'
- | 'dark'
- | undefined
- if (!appearance) return
- setColorMode(appearance)
+ (value: 'light' | 'system' | 'dark') => {
+ setColorMode(value)
},
- [setColorMode, colorMode],
+ [setColorMode],
)
const onChangeDarkTheme = useCallback(
- (keys: string[]) => {
- const theme = keys.find(key => key !== darkTheme) as
- | 'dim'
- | 'dark'
- | undefined
- if (!theme) return
- setDarkTheme(theme)
+ (value: 'dim' | 'dark') => {
+ setDarkTheme(value)
},
- [setDarkTheme, darkTheme],
+ [setDarkTheme],
)
const onChangeFontFamily = useCallback(
- (values: string[]) => {
- const next = values[0] === 'system' ? 'system' : 'theme'
- fonts.setFontFamily(next)
+ (value: 'system' | 'theme') => {
+ fonts.setFontFamily(value)
},
[fonts],
)
const onChangeFontScale = useCallback(
- (values: string[]) => {
- const next = values[0] || ('0' as any)
- fonts.setFontScale(next)
+ (value: Alf['fonts']['scale']) => {
+ fonts.setFontScale(value)
},
[fonts],
)
@@ -107,7 +94,7 @@ export function AppearanceSettingsScreen({}: Props) {
name: 'dark',
},
]}
- values={[colorMode]}
+ value={colorMode}
onChange={onChangeAppearance}
/>
@@ -128,7 +115,7 @@ export function AppearanceSettingsScreen({}: Props) {
name: 'dark',
},
]}
- values={[darkTheme ?? 'dim']}
+ value={darkTheme ?? 'dim'}
onChange={onChangeDarkTheme}
/>
@@ -153,7 +140,7 @@ export function AppearanceSettingsScreen({}: Props) {
name: 'theme',
},
]}
- values={[fonts.family]}
+ value={fonts.family}
onChange={onChangeFontFamily}
/>
@@ -174,7 +161,7 @@ export function AppearanceSettingsScreen({}: Props) {
name: '1',
},
]}
- values={[fonts.scale]}
+ value={fonts.scale}
onChange={onChangeFontScale}
/>
@@ -192,12 +179,12 @@ export function AppearanceSettingsScreen({}: Props) {
)
}
-export function AppearanceToggleButtonGroup({
+export function AppearanceToggleButtonGroup({
title,
description,
icon: Icon,
items,
- values,
+ value,
onChange,
}: {
title: string
@@ -205,10 +192,10 @@ export function AppearanceToggleButtonGroup({
icon: React.ComponentType
items: {
label: string
- name: string
+ name: T
}[]
- values: string[]
- onChange: (values: string[]) => void
+ value: T
+ onChange: (value: T) => void
}) {
const t = useTheme()
return (
@@ -227,16 +214,22 @@ export function AppearanceToggleButtonGroup({
{description}
)}
-
+
{items.map(item => (
-
- {item.label}
-
+ value={item.name}>
+
+ {item.label}
+
+
))}
-
+
>
)
diff --git a/src/screens/Settings/components/ChangeHandleDialog.tsx b/src/screens/Settings/components/ChangeHandleDialog.tsx
index 2187c5fcc4..accbf41c86 100644
--- a/src/screens/Settings/components/ChangeHandleDialog.tsx
+++ b/src/screens/Settings/components/ChangeHandleDialog.tsx
@@ -29,8 +29,8 @@ import {atoms as a, native, useBreakpoints, useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
+import * as SegmentedControl from '#/components/forms/SegmentedControl'
import * as TextField from '#/components/forms/TextField'
-import * as ToggleButton from '#/components/forms/ToggleButton'
import {
ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon,
ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon,
@@ -395,21 +395,22 @@ function OwnHandlePage({goToServiceHandle}: {goToServiceHandle: () => void}) {
/>
- setDNSPanel(values[0] === 'dns')}>
-
-
+ type="tabs"
+ value={dnsPanel ? 'dns' : 'file'}
+ onChange={values => setDNSPanel(values === 'dns')}>
+
+ DNS Panel
-
-
-
-
+
+
+
+ No DNS Panel
-
-
-
+
+
+
{dnsPanel ? (
<>
diff --git a/src/view/screens/Storybook/Forms.tsx b/src/view/screens/Storybook/Forms.tsx
index 3cf6e47232..a29aa70ab7 100644
--- a/src/view/screens/Storybook/Forms.tsx
+++ b/src/view/screens/Storybook/Forms.tsx
@@ -4,6 +4,7 @@ import {type TextInput, View} from 'react-native'
import {atoms as a} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {DateField, LabelText} from '#/components/forms/DateField'
+import * as SegmentedControl from '#/components/forms/SegmentedControl'
import * as TextField from '#/components/forms/TextField'
import * as Toggle from '#/components/forms/Toggle'
import * as ToggleButton from '#/components/forms/ToggleButton'
@@ -15,6 +16,9 @@ export function Forms() {
const [toggleGroupBValues, setToggleGroupBValues] = React.useState(['a', 'b'])
const [toggleGroupCValues, setToggleGroupCValues] = React.useState(['a', 'b'])
const [toggleGroupDValues, setToggleGroupDValues] = React.useState(['warn'])
+ const [segmentedControlValue, setSegmentedControlValue] = React.useState<
+ 'hide' | 'warn' | 'show'
+ >('warn')
const [value, setValue] = React.useState('')
const [date, setDate] = React.useState('2001-01-01')
@@ -254,23 +258,26 @@ export function Forms() {
Show
+
-
-
-
- Hide
-
-
- Warn
-
-
- Show
-
-
-
+
+