From 92926a2417af8fb6f37feb93779f8cf4c4a4b622 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 14 Nov 2025 21:43:28 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20`SegmentedControl`=20component=20(#?= =?UTF-8?q?8606)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .eslintrc.js | 1 + src/components/dialogs/Embed.tsx | 35 +-- .../dialogs/ServerInput.tsx} | 108 ++++--- src/components/forms/HostingProvider.tsx | 2 +- src/components/forms/SegmentedControl.tsx | 284 ++++++++++++++++++ src/components/forms/ToggleButton.tsx | 15 +- src/screens/Settings/AppearanceSettings.tsx | 73 ++--- .../components/ChangeHandleDialog.tsx | 27 +- src/view/screens/Storybook/Forms.tsx | 39 ++- 9 files changed, 445 insertions(+), 139 deletions(-) rename src/{view/com/auth/server-input/index.tsx => components/dialogs/ServerInput.tsx} (73%) create mode 100644 src/components/forms/SegmentedControl.tsx 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 - - - + +

SegmentedControl

+ + + + Hide + + + Warn + + + Show + +
)