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:
Samuel Newman
2025-11-14 21:43:28 +02:00
committed by GitHub
parent 7a8ab551e3
commit 92926a2417
9 changed files with 445 additions and 139 deletions
+1
View File
@@ -43,6 +43,7 @@ module.exports = {
suggestedTextWrappers: { suggestedTextWrappers: {
Button: 'ButtonText', Button: 'ButtonText',
'ToggleButton.Button': 'ToggleButton.ButtonText', 'ToggleButton.Button': 'ToggleButton.ButtonText',
'SegmentedControl.Item': 'SegmentedControl.ItemText',
}, },
}, },
], ],
+18 -17
View File
@@ -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>
+1 -1
View File
@@ -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'
+284
View File
@@ -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],
}),
]}
/>
)
}
+12 -3
View File
@@ -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)
+33 -40
View File
@@ -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>
+23 -16
View File
@@ -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>
) )